From fb211ca1c53c631dddeabeea28c52688fa3edd1f Mon Sep 17 00:00:00 2001 From: Jorge Cuadrado Date: Mon, 22 Dec 2025 12:42:13 -0500 Subject: [PATCH] Implement minidyn as a server mode. Why: To let teams use minidyn via httptest/NewServer with AWS SDK clients without maintaining separate SDK-specific variants. What: - Add HTTP server mode with generated JSON request structs and concrete AttributeValue - Implement mapper/client/server wiring plus HTTP tests for CRUD, GSI, batch, scan, update/delete, conditional failures - Document generator tool and HTTP usage, including how to regenerate requests Issue: #80 --- .golangci.yml | 79 ++- README.md | 72 +- aws-v1/client/client_test.go | 32 +- aws-v2/client/client_test.go | 5 +- core/table_test.go | 2 +- go.sum | 51 -- interpreter/language/evaluator_test.go | 3 +- interpreter/language/lexer_test.go | 26 +- interpreter/language/object_test.go | 1 + server/client.go | 426 ++++++++++++ server/mapper.go | 408 +++++++++++ server/minidyn.go | 41 ++ server/requests.go | 237 +++++++ server/responses.go | 65 ++ server/server.go | 153 +++++ server/server_test.go | 633 ++++++++++++++++++ server/types.go | 16 + tools/generate_requests/generate_requests.go | 255 +++++++ .../generate_requests_test.go | 33 + types/errors.go | 7 +- types/errors_test.go | 69 ++ 21 files changed, 2494 insertions(+), 120 deletions(-) create mode 100644 server/client.go create mode 100644 server/mapper.go create mode 100644 server/minidyn.go create mode 100644 server/requests.go create mode 100644 server/responses.go create mode 100644 server/server.go create mode 100644 server/server_test.go create mode 100644 server/types.go create mode 100644 tools/generate_requests/generate_requests.go create mode 100644 tools/generate_requests/generate_requests_test.go create mode 100644 types/errors_test.go diff --git a/.golangci.yml b/.golangci.yml index 8a2ed73..f6763c7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,17 +1,25 @@ +# yamllint disable-line rule:line-length # yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json version: "2" run: - timeout: 40s + timeout: 240s tests: true + allow-parallel-runners: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 formatters: enable: + - gofumpt - goimports linters: default: none enable: - errcheck + - errorlint - gocritic - gocyclo - gocognit @@ -20,13 +28,14 @@ linters: - govet - ineffassign - staticcheck + - wsl_v5 + - nilnesserr - nakedret - - unused - - wsl + settings: revive: severity: warning - confidence: 0.3 + confidence: 0.8 rules: - name: blank-imports - name: context-as-argument @@ -55,8 +64,7 @@ linters: - name: identical-branches - name: defer arguments: - - - - loop + - - loop - method-call - return - name: string-of-int @@ -72,33 +80,66 @@ linters: arguments: - 5 - name: modifies-value-receiver - - name: modifies-parameter - name: unnecessary-stmt gocyclo: min-complexity: 9 gocognit: min-complexity: 10 - errcheck: - check-type-assertions: true - govet: + gocritic: + enable-all: true + disabled-checks: + - ifElseChain + - unnecessaryBlock + - sprintfQuotedString + - deferUnlambda + - paramTypeCombine + - builtinShadow + - octalLiteral + - rangeValCopy + - nestingReduce + - httpNoBody + - regexpSimplify + - externalErrorReassign + - hugeParam + - importShadow + - unnamedResult + - filepathJoin + - commentedOutCode + wsl_v5: + allow-first-in-block: true + allow-whole-block: false + branch-max-lines: 2 enable: - - shadow - wsl: - allow-assign-and-call: true - allow-cuddle-declarations: true - force-err-cuddling: true + - err + disable: + - assign + - decl gosec: excludes: - - G104 + - "G115" + - "G602" staticcheck: checks: - all - -QF1008 - + errcheck: + check-type-assertions: true + govet: + enable: + - shadow + - nilness + exclusions: + generated: disable rules: - - path: (.+)_test\.go + - path: autogenerated\.go$ linters: - gocyclo - - gosec - gocognit + + - path: '(.+)_test\.go' + linters: + - errcheck + - staticcheck + - wsl + - wsl_v5 diff --git a/README.md b/README.md index c6435eb..91ef258 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Amazon DynamoDB testing library written in Go. ## Usage +### In-memory client (existing) + Create the dynamodb client: ```go @@ -53,6 +55,36 @@ if err != nil { **NOTE** these methods only support string attributes. +### HTTP server mode (new) + +You can now run minidyn as an HTTP server compatible with the DynamoDB JSON API. This is handy for using `httptest.NewServer` and real AWS SDK clients without swapping implementations. + +```go +import ( + "net/http/httptest" + + miniserver "github.com/truora/minidyn/server" + "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" +) + +srv := httptest.NewServer(miniserver.NewServer()) +defer srv.Close() + +cfg, _ := config.LoadDefaultConfig(ctx, + config.WithEndpointResolverWithOptions( + aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{URL: srv.URL, PartitionID: "aws", SigningRegion: "us-east-1"}, nil + })), +) +ddb := dynamodb.NewFromConfig(cfg) + +// use ddb as usual: CreateTable, PutItem, Query, etc. +``` + +Supported operations in HTTP mode include CreateTable, DescribeTable, UpdateTable, DeleteTable, PutItem, GetItem, UpdateItem, DeleteItem, Query, Scan, and BatchWriteItem. + ## Language interpreter This library has an interpreter implementation for the DynamoDB Expressions. @@ -62,22 +94,22 @@ This library has an interpreter implementation for the DynamoDB Expressions. #### Types | Name | Type | Short | Supported? | -|-----------|----------|-------|-----------| -| Number | scalar | N | y | -| String | scalar | S | y | -| Binary | scalar | B | y | -| Bool | scalar | BOOL | y | -| Null | scalar | NULL | y | -| List | document | L | y | -| Map | document | M | y | -| StringSet | set | SS | y | -| NumberSet | set | NS | y | -| BinarySet | set | BS | y | +| --------- | -------- | ----- | ---------- | +| Number | scalar | N | y | +| String | scalar | S | y | +| Binary | scalar | B | y | +| Bool | scalar | BOOL | y | +| Null | scalar | NULL | y | +| List | document | L | y | +| Map | document | M | y | +| StringSet | set | SS | y | +| NumberSet | set | NS | y | +| BinarySet | set | BS | y | #### Expressions -| |Syntax | Supported? | -|----------------------------------------------|-------------------------------------------------------------------------------------|------------| +| | Syntax | Supported? | +| -------------------------------------------- | ----------------------------------------------------------------------------------- | ---------- | | operand comparator operand | = <> < <= > and >= | y | | operand BETWEEN operand AND operand | N,S,B | y | | operand IN ( operand (',' operand (, ...) )) | | y | @@ -91,13 +123,25 @@ This library has an interpreter implementation for the DynamoDB Expressions. #### Expressions | | Syntax | Supported? | -|----------|------------------------------|------------| +| -------- | ---------------------------- | ---------- | | SET | SET action [, action] ... | y | | REMOVE | REMOVE action [, action] ... | y | | ADD | ADD action [, action] ... | y | | DELETE | DELETE action [, action] ... | y | | function | list_append, if_not_exists | y | +## Developer notes + +### Regenerating HTTP request structs + +The HTTP server uses generated JSON input shapes in `server/requests.go` so we can cleanly unmarshal DynamoDB JSON without the SDK’s `AttributeValue` interfaces. If you update DynamoDB inputs or need to refresh these shapes, run: + +```bash +go run ./tools/generate_requests +``` + +This will rewrite `server/requests.go` based on the AWS SDK v2 DynamoDB input types, replacing `AttributeValue` interfaces with the concrete JSON-friendly `AttributeValue` defined in `server/types.go`. + ### What to do when the interpreter does not work properly? When it happens you can override the intepretation using like this: diff --git a/aws-v1/client/client_test.go b/aws-v1/client/client_test.go index 387d872..459d90c 100644 --- a/aws-v1/client/client_test.go +++ b/aws-v1/client/client_test.go @@ -466,7 +466,7 @@ func TestUpdateTable(t *testing.T) { }, }, GlobalSecondaryIndexUpdates: []*dynamodb.GlobalSecondaryIndexUpdate{ - &dynamodb.GlobalSecondaryIndexUpdate{ + { Create: &dynamodb.CreateGlobalSecondaryIndexAction{ IndexName: aws.String("newIndex"), KeySchema: []*dynamodb.KeySchemaElement{ @@ -494,7 +494,7 @@ func TestUpdateTable(t *testing.T) { input = &dynamodb.UpdateTableInput{ GlobalSecondaryIndexUpdates: []*dynamodb.GlobalSecondaryIndexUpdate{ - &dynamodb.GlobalSecondaryIndexUpdate{ + { Update: &dynamodb.UpdateGlobalSecondaryIndexAction{ IndexName: aws.String("newIndex"), ProvisionedThroughput: &dynamodb.ProvisionedThroughput{ @@ -511,7 +511,7 @@ func TestUpdateTable(t *testing.T) { input = &dynamodb.UpdateTableInput{ GlobalSecondaryIndexUpdates: []*dynamodb.GlobalSecondaryIndexUpdate{ - &dynamodb.GlobalSecondaryIndexUpdate{ + { Delete: &dynamodb.DeleteGlobalSecondaryIndexAction{ IndexName: aws.String("newIndex"), }, @@ -1012,7 +1012,7 @@ func TestUpdateExpressions(t *testing.T) { }, ReturnValues: aws.String("UPDATED_NEW"), UpdateExpression: aws.String("ADD lvl :one"), - ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{":one": &dynamodb.AttributeValue{N: aws.String("1")}}, + ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{":one": {N: aws.String("1")}}, }, verify: func(tc *testing.T, client dynamodbiface.DynamoDBAPI) { a := assert.New(tc) @@ -1140,7 +1140,7 @@ func TestQueryWithContext(t *testing.T) { c.Len(out.Items, 1) c.Empty(out.LastEvaluatedKey) - var pokemonQueried = pokemon{} + pokemonQueried := pokemon{} err = dynamodbattribute.UnmarshalMap(out.Items[0], &pokemonQueried) c.NoError(err) @@ -1721,7 +1721,7 @@ func TestBatchWriteItemWithContext(t *testing.T) { c.NoError(err) requests := []*dynamodb.WriteRequest{ - &dynamodb.WriteRequest{ + { PutRequest: &dynamodb.PutRequest{ Item: item, }, @@ -1757,10 +1757,10 @@ func TestBatchWriteItemWithContext(t *testing.T) { _, err = client.BatchWriteItemWithContext(context.Background(), &dynamodb.BatchWriteItemInput{ RequestItems: map[string][]*dynamodb.WriteRequest{ - tableName: []*dynamodb.WriteRequest{ - &dynamodb.WriteRequest{ + tableName: { + { DeleteRequest: &dynamodb.DeleteRequest{Key: map[string]*dynamodb.AttributeValue{ - "id": &dynamodb.AttributeValue{S: aws.String("001")}, + "id": {S: aws.String("001")}, }}, }, }, @@ -1777,11 +1777,11 @@ func TestBatchWriteItemWithContext(t *testing.T) { _, err = client.BatchWriteItemWithContext(context.Background(), &dynamodb.BatchWriteItemInput{ RequestItems: map[string][]*dynamodb.WriteRequest{ - tableName: []*dynamodb.WriteRequest{ - &dynamodb.WriteRequest{ + tableName: { + { DeleteRequest: &dynamodb.DeleteRequest{Key: map[string]*dynamodb.AttributeValue{ - "id": &dynamodb.AttributeValue{S: aws.String("001")}, - "type": &dynamodb.AttributeValue{S: aws.String("grass")}, + "id": {S: aws.String("001")}, + "type": {S: aws.String("grass")}, }}, PutRequest: &dynamodb.PutRequest{Item: item}, }, @@ -1793,8 +1793,8 @@ func TestBatchWriteItemWithContext(t *testing.T) { _, err = client.BatchWriteItemWithContext(context.Background(), &dynamodb.BatchWriteItemInput{ RequestItems: map[string][]*dynamodb.WriteRequest{ - tableName: []*dynamodb.WriteRequest{ - &dynamodb.WriteRequest{}, + tableName: { + {}, }, }, }) @@ -1834,7 +1834,7 @@ func TestBatchWriteItemWithFailingDatabase(t *testing.T) { c.NoError(err) requests := []*dynamodb.WriteRequest{ - &dynamodb.WriteRequest{ + { PutRequest: &dynamodb.PutRequest{ Item: item, }, diff --git a/aws-v2/client/client_test.go b/aws-v2/client/client_test.go index 4469ee3..5360fc7 100644 --- a/aws-v2/client/client_test.go +++ b/aws-v2/client/client_test.go @@ -1986,9 +1986,8 @@ func TestBatchWriteItemWithFailingDatabase(t *testing.T) { } output, err := client.BatchWriteItem(context.Background(), input) - c.NoError(err) - - c.NotEmpty(output.UnprocessedItems) + c.EqualError(err, "InternalServerError: emulated error") + c.Nil(output) } func TestTransactWriteItems(t *testing.T) { diff --git a/core/table_test.go b/core/table_test.go index 1b8283c..2c12d9c 100644 --- a/core/table_test.go +++ b/core/table_test.go @@ -519,7 +519,7 @@ func TestSearchData(t *testing.T) { queryInput.started = true result, lastItem = newTable.SearchData(queryInput) - c.Equal([]map[string]*types.Item{{}}, result) + c.Equal([]map[string]*types.Item{}, result) c.Equal(map[string]*types.Item{}, lastItem) queryInput.ExclusiveStartKey = item diff --git a/go.sum b/go.sum index cdc7e89..4234315 100644 --- a/go.sum +++ b/go.sum @@ -1,114 +1,63 @@ -github.com/aws/aws-sdk-go v1.40.12 h1:66+IAWhl+aaZCW1+ndS/GNfAxy8tJca2cMoIF2O325I= -github.com/aws/aws-sdk-go v1.40.12/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= github.com/aws/aws-sdk-go v1.55.7/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= github.com/aws/aws-sdk-go-v2 v1.16.16/go.mod h1:SwiyXi/1zTUZ6KIAmLK5V5ll8SiURNUYOqTerZPaF9k= -github.com/aws/aws-sdk-go-v2 v1.25.0 h1:sv7+1JVJxOu/dD/sz/csHX7jFqmP001TIY7aytBWDSQ= -github.com/aws/aws-sdk-go-v2 v1.25.0/go.mod h1:G104G1Aho5WqF+SR3mDIobTABQzpYV0WxMsKxlMggOA= github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= github.com/aws/aws-sdk-go-v2/config v1.17.8 h1:b9LGqNnOdg9vR4Q43tBTVWk4J6F+W774MSchvKJsqnE= github.com/aws/aws-sdk-go-v2/config v1.17.8/go.mod h1:UkCI3kb0sCdvtjiXYiU4Zx5h07BOpgBTtkPu/49r+kA= -github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= -github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= github.com/aws/aws-sdk-go-v2/credentials v1.12.21 h1:4tjlyCD0hRGNQivh5dN8hbP30qQhMLBE/FgQR1vHHWM= github.com/aws/aws-sdk-go-v2/credentials v1.12.21/go.mod h1:O+4XyAt4e+oBAoIwNUYkRg3CVMscaIJdmZBOcPgJ8D8= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= -github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.0 h1:bKbdstt7+PzIRSIXZ11Yo8Qh8t0AHn6jEYUfsbVcLjE= github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.10.0/go.mod h1:+CBJZMhsb1pTUcB/NTdS505bDX10xS4xnPMqDZj2Ptw= -github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.0 h1:F3W0YqWZrpCcelbvXMP9LWSTOI620aAq1+8fZ/71TBg= -github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.19.0/go.mod h1:34X+UzFJwsQfyk5U1hYiCO/gv9ZVL+Hh8w+bJQ6+HbU= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.17 h1:r08j4sbZu/RVi+BNxkBJwPMUYY3P8mgSDuKkZ/ZN1lE= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.17/go.mod h1:yIkQcCDYNsZfXpd5UX2Cy+sWA1jPgIhGTw9cOBzfVnQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23/go.mod h1:2DFxAQ9pfIRy0imBCJv+vZ2X6RKxves6fbnEuSry6b4= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.0 h1:NPs/EqVO+ajwOoq56EfcGKa3L3ruWuazkIw1BqxwOPw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.0/go.mod h1:D+duLy2ylgatV+yTlQ8JTuLfDD0BnFvnQRc+o6tbZ4M= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17/go.mod h1:pRwaTYCJemADaqCbUAxltMoHKata7hmB5PjEXeu0kfg= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.0 h1:ks7KGMVUMoDzcxNWUlEdI+/lokMFD136EL6DWmUOV80= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.0/go.mod h1:hL6BWM/d/qz113fVitZjbXR0E+RCTU1+x+1Idyn5NgE= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.24 h1:wj5Rwc05hvUSvKuOF29IYb9QrCLjU+rHAy/x/o0DK2c= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.24/go.mod h1:jULHjqqjDlbyTa7pfM7WICATnOv+iOhjletM3N0Xbu8= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= -github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.17.1/go.mod h1:BZhn/C3z13ULTSstVi2Kymc62bgjFh/JwLO9Tm2OFYI= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.29.0 h1:zZP5rgaQYyDw0nNZRsbYqwC4NS/KsmVKGSwm0EzYAzU= -github.com/aws/aws-sdk-go-v2/service/dynamodb v1.29.0/go.mod h1:DxfpJjhSt8Aab1PszcEo63xxUo6mzyUX5shTcxo8LSc= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.1 h1:YYjNTAyPL0425ECmq6Xm48NSXdT6hDVQmLOJZxyhNTM= github.com/aws/aws-sdk-go-v2/service/dynamodb v1.43.1/go.mod h1:yYaWRnVSPyAmexW5t7G3TcuYoalYfT+xQwzWsvtUQ7M= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.20 h1:V9q4A0qnUfDsfivspY1LQRQTOG3Y9FLHvXIaTbcU7XM= github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.20/go.mod h1:7qWU48SMzlrfOlNhHpazW3psFWlOIWrq4SmOr2/ESmk= -github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.3 h1:GHC1WTF3ZBZy+gvz2qtYB6ttALVx35hlwc4IzOIUY7g= -github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.3/go.mod h1:lUqWdw5/esjPTkITXhN4C66o1ltwDq2qQ12j3SOzhVg= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.9/go.mod h1:a9j48l6yL5XINLHLcOKInjdvknN+vWqPBxqeIDw7ktw= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.0 h1:a33HuFlO0KsveiP90IUJh8Xr/cx9US2PqkSroaLc+o8= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.0/go.mod h1:SxIkWpByiGbhbHYTo9CMTUnx2G4p4ZQMrDPcRRy//1c= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.17/go.mod h1:WJD9FbkwzM2a1bZ36ntH6+5Jc+x41Q4K2AcLeHDLAS8= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.0 h1:iUs6gEpVk7JbPfgYvOvfbMiv4lfF7fRtey4GCm57qAY= -github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.9.0/go.mod h1:NEV6CinaaXxW+97YglxVlKn9+83VR0L5O/BIrwqsFvU= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.15 h1:M1R1rud7HzDrfCdlBQ7NjnRsDNEhXO/vGhuD189Ggmk= github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.15/go.mod h1:uvFKBSq9yMPV4LGAi7N4awn4tLY+hKE35f8THes2mzQ= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17 h1:Jrd/oMh0PKQc6+BowB+pLEwLIgaQF29eYbe7E1Av9Ug= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.17/go.mod h1:4nYOrY41Lrbk2170/BGkcJKBhws9Pfn8MG3aGqjjeFI= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= github.com/aws/aws-sdk-go-v2/service/sso v1.11.23 h1:pwvCchFUEnlceKIgPUouBJwK81aCkQ8UDMORfeFtW10= github.com/aws/aws-sdk-go-v2/service/sso v1.11.23/go.mod h1:/w0eg9IhFGjGyyncHIQrXtU8wvNsTJOP0R6PPj0wf80= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= -github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.6 h1:OwhhKc1P9ElfWbMKPIbMMZBV6hzJlL2JKD76wNNVzgQ= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.13.6/go.mod h1:csZuQY65DAdFBt1oIjO5hhBR49kQqop4+lcuCjf2arA= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= github.com/aws/aws-sdk-go-v2/service/sts v1.16.19 h1:9pPi0PsFNAGILFfPCk8Y0iyEBGc6lu6OQ97U7hmdesg= github.com/aws/aws-sdk-go-v2/service/sts v1.16.19/go.mod h1:h4J3oPZQbxLhzGnk+j9dfYHi5qIOVJ5kczZd658/ydM= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= -github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= github.com/aws/smithy-go v1.13.3/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= -github.com/aws/smithy-go v1.20.0 h1:6+kZsCXZwKxZS9RfISnPc4EXlHoyAkm2hPuM8X2BrrQ= -github.com/aws/smithy-go v1.20.0/go.mod h1:uo5RKksAl4PzhqaAbjd4rLgFoq5koTsQKYuGe7dklGc= github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k= github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/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= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 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.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/interpreter/language/evaluator_test.go b/interpreter/language/evaluator_test.go index c230359..189a489 100644 --- a/interpreter/language/evaluator_test.go +++ b/interpreter/language/evaluator_test.go @@ -636,7 +636,7 @@ func TestErrorHandling(t *testing.T) { }, { "list_append(:list,:x)", - "the function is not allowed in an condition expression; function: list_append", + "the function is not allowed in a condition expression; function: list_append", }, { "ROLE IN (:x, :str)", @@ -729,6 +729,7 @@ func TestEvalUpdateReservedKeywords(t *testing.T) { } } +//nolint:gocognit // multiple table-driven scenarios for keyword validation func TestEvalReservedKeywords(t *testing.T) { tests := []struct { input string diff --git a/interpreter/language/lexer_test.go b/interpreter/language/lexer_test.go index 11c3290..7b7e4f6 100644 --- a/interpreter/language/lexer_test.go +++ b/interpreter/language/lexer_test.go @@ -11,28 +11,28 @@ type testCase struct { func TestNextToken(t *testing.T) { table := map[string][]testCase{ - `v1`: []testCase{ + `v1`: { {IDENT, "v1"}, }, - `a = b AND c`: []testCase{ + `a = b AND c`: { {IDENT, "a"}, {EQ, "="}, {IDENT, "b"}, {AND, "AND"}, {IDENT, "c"}, }, - `a <> b`: []testCase{ + `a <> b`: { {IDENT, "a"}, {NotEQ, "<>"}, {IDENT, "b"}, }, - `attribute_exists(:a)`: []testCase{ + `attribute_exists(:a)`: { {IDENT, "attribute_exists"}, {LPAREN, "("}, {IDENT, ":a"}, {RPAREN, ")"}, }, - `begins_with(:a, #s)`: []testCase{ + `begins_with(:a, #s)`: { {IDENT, "begins_with"}, {LPAREN, "("}, {IDENT, ":a"}, @@ -40,7 +40,7 @@ func TestNextToken(t *testing.T) { {IDENT, "#s"}, {RPAREN, ")"}, }, - `contains(:a, #s)`: []testCase{ + `contains(:a, #s)`: { {IDENT, "contains"}, {LPAREN, "("}, {IDENT, ":a"}, @@ -48,7 +48,7 @@ func TestNextToken(t *testing.T) { {IDENT, "#s"}, {RPAREN, ")"}, }, - `a <= b AND b >= c`: []testCase{ + `a <= b AND b >= c`: { {IDENT, "a"}, {LTE, "<="}, {IDENT, "b"}, @@ -57,7 +57,7 @@ func TestNextToken(t *testing.T) { {GTE, ">="}, {IDENT, "c"}, }, - `a IN (b, c)`: []testCase{ + `a IN (b, c)`: { {IDENT, "a"}, {IN, "IN"}, {LPAREN, "("}, @@ -66,28 +66,28 @@ func TestNextToken(t *testing.T) { {IDENT, "c"}, {RPAREN, ")"}, }, - `NOT a`: []testCase{ + `NOT a`: { {NOT, "NOT"}, {IDENT, "a"}, }, - `a >`: []testCase{ + `a >`: { {IDENT, "a"}, {GT, ">"}, }, - `b BETWEEN a AND c`: []testCase{ + `b BETWEEN a AND c`: { {IDENT, "b"}, {BETWEEN, "BETWEEN"}, {IDENT, "a"}, {AND, "AND"}, {IDENT, "c"}, }, - `a[1]`: []testCase{ + `a[1]`: { {IDENT, "a"}, {LBRACKET, "["}, {IDENT, "1"}, {RBRACKET, "]"}, }, - `a.f`: []testCase{ + `a.f`: { {IDENT, "a"}, {DOT, "."}, {IDENT, "f"}, diff --git a/interpreter/language/object_test.go b/interpreter/language/object_test.go index 8f877eb..cefd0e8 100644 --- a/interpreter/language/object_test.go +++ b/interpreter/language/object_test.go @@ -486,6 +486,7 @@ func BenchmarkStringInspect(b *testing.B) { } } +//nolint:gocyclo // comprehensive conversions covered in one test func TestToDynamo(t *testing.T) { num := Number{Value: 3} dNum := num.ToDynamoDB() diff --git a/server/client.go b/server/client.go new file mode 100644 index 0000000..cdeb1ef --- /dev/null +++ b/server/client.go @@ -0,0 +1,426 @@ +package server + +import ( + "context" + "errors" + "sync" + + "github.com/aws/aws-sdk-go-v2/aws" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/aws/smithy-go" + "github.com/truora/minidyn/core" + "github.com/truora/minidyn/interpreter" + "github.com/truora/minidyn/types" +) + +// Client implements a DynamoDB-like engine backed by core.Table. +type Client struct { + tables map[string]*core.Table + mu sync.Mutex + langInterpreter *interpreter.Language + nativeInterpreter *interpreter.Native + useNativeInterpreter bool + forceFailureErr error +} + +// NewClient creates a new in-memory DynamoDB-compatible client used by the HTTP server. +func NewClient() *Client { + return &Client{ + tables: map[string]*core.Table{}, + mu: sync.Mutex{}, + nativeInterpreter: interpreter.NewNativeInterpreter(), + langInterpreter: &interpreter.Language{}, + } +} + +func (c *Client) setFailureCondition(err error) { + c.forceFailureErr = err +} + +// Table helpers +func (c *Client) getTable(tableName string) (*core.Table, error) { + table, ok := c.tables[tableName] + if !ok { + return nil, &ddbtypes.ResourceNotFoundException{Message: aws.String("Cannot do operations on a non-existent table")} + } + + return table, nil +} + +// CreateTable creates a new table in the in-memory engine. +func (c *Client) CreateTable(ctx context.Context, input *CreateTableInput) (*CreateTableOutput, error) { + tableName := aws.ToString(input.TableName) + if _, ok := c.tables[tableName]; ok { + return nil, &ddbtypes.ResourceInUseException{Message: aws.String("Cannot create preexisting table")} + } + + table := core.NewTable(tableName) + table.SetAttributeDefinition(mapAttributeDefinitions(input.AttributeDefinitions)) + table.BillingMode = toStringPtr(string(input.BillingMode)) + table.NativeInterpreter = *c.nativeInterpreter + table.UseNativeInterpreter = c.useNativeInterpreter + table.LangInterpreter = *c.langInterpreter + + if err := table.CreatePrimaryIndex(&types.CreateTableInput{ + KeySchema: mapKeySchema(input.KeySchema), + ProvisionedThroughput: mapProvisionedThroughput(input.ProvisionedThroughput), + }); err != nil { + return nil, mapKnownError(err) + } + + if err := table.AddGlobalIndexes(mapGSI(input.GlobalSecondaryIndexes)); err != nil { + return nil, mapKnownError(err) + } + + if err := table.AddLocalIndexes(mapLSI(input.LocalSecondaryIndexes)); err != nil { + return nil, mapKnownError(err) + } + + c.tables[tableName] = table + + return &CreateTableOutput{TableDescription: mapTableDescriptionToDDB(table.Description(tableName))}, nil +} + +// UpdateTable applies metadata changes, including GSI updates. +func (c *Client) UpdateTable(ctx context.Context, input *UpdateTableInput) (*UpdateTableOutput, error) { + tableName := aws.ToString(input.TableName) + + table, ok := c.tables[tableName] + if !ok { + return nil, &ddbtypes.ResourceNotFoundException{Message: aws.String("Cannot do operations on a non-existent table")} + } + + if input.AttributeDefinitions != nil { + table.SetAttributeDefinition(mapAttributeDefinitions(input.AttributeDefinitions)) + } + + for _, change := range mapGSIUpdate(input.GlobalSecondaryIndexUpdates) { + if err := table.ApplyIndexChange(change); err != nil { + return &UpdateTableOutput{TableDescription: mapTableDescriptionToDDB(table.Description(tableName))}, mapKnownError(err) + } + } + + return &UpdateTableOutput{TableDescription: mapTableDescriptionToDDB(table.Description(tableName))}, nil +} + +// DeleteTable removes a table and its data. +func (c *Client) DeleteTable(ctx context.Context, input *DeleteTableInput) (*DeleteTableOutput, error) { + tableName := aws.ToString(input.TableName) + + table, err := c.getTable(tableName) + if err != nil { + return nil, err + } + + desc := mapTableDescriptionToDDB(table.Description(tableName)) + delete(c.tables, tableName) + + return &DeleteTableOutput{TableDescription: desc}, nil +} + +// DescribeTable returns table metadata. +func (c *Client) DescribeTable(ctx context.Context, input *DescribeTableInput) (*DescribeTableOutput, error) { + tableName := aws.ToString(input.TableName) + + table, err := c.getTable(tableName) + if err != nil { + return nil, err + } + + return &DescribeTableOutput{Table: mapTableDescriptionToDDB(table.Description(tableName))}, nil +} + +// PutItem inserts or replaces an item. +func (c *Client) PutItem(ctx context.Context, input *PutItemInput) (*PutItemOutput, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.forceFailureErr != nil { + return nil, c.forceFailureErr + } + + table, err := c.getTable(aws.ToString(input.TableName)) + if err != nil { + return nil, err + } + + item, err := table.Put(&types.PutItemInput{ + TableName: input.TableName, + ConditionExpression: input.ConditionExpression, + ConditionalOperator: toStringPtr(string(input.ConditionalOperator)), + ExpressionAttributeNames: input.ExpressionAttributeNames, + ExpressionAttributeValues: mapAttributeValueMapToTypes(input.ExpressionAttributeValues), + Item: mapAttributeValueMapToTypes(input.Item), + ReturnConsumedCapacity: toStringPtr(string(input.ReturnConsumedCapacity)), + ReturnItemCollectionMetrics: toStringPtr(string(input.ReturnItemCollectionMetrics)), + ReturnValues: toStringPtr(string(input.ReturnValues)), + }) + if err != nil { + return nil, mapKnownError(err) + } + + return &PutItemOutput{Attributes: mapTypesMapToAttributeValue(item)}, nil +} + +// DeleteItem removes an item and optionally returns old values. +func (c *Client) DeleteItem(ctx context.Context, input *DeleteItemInput) (*DeleteItemOutput, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.forceFailureErr != nil { + return nil, c.forceFailureErr + } + + table, err := c.getTable(aws.ToString(input.TableName)) + if err != nil { + return nil, err + } + + item, err := table.Delete(&types.DeleteItemInput{ + TableName: input.TableName, + ConditionExpression: input.ConditionExpression, + ConditionalOperator: toStringPtr(string(input.ConditionalOperator)), + ExpressionAttributeNames: toStringPtrMap(input.ExpressionAttributeNames), + ExpressionAttributeValues: mapAttributeValueMapToTypes(input.ExpressionAttributeValues), + Key: mapAttributeValueMapToTypes(input.Key), + }) + if err != nil { + return nil, mapKnownError(err) + } + + if input.ReturnValues == ddbtypes.ReturnValueAllOld { + return &DeleteItemOutput{Attributes: mapTypesMapToAttributeValue(item)}, nil + } + + return &DeleteItemOutput{}, nil +} + +// UpdateItem modifies attributes of an item using an update expression. +func (c *Client) UpdateItem(ctx context.Context, input *UpdateItemInput) (*UpdateItemOutput, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.forceFailureErr != nil { + return nil, c.forceFailureErr + } + + table, err := c.getTable(aws.ToString(input.TableName)) + if err != nil { + return nil, err + } + + item, err := table.Update(&types.UpdateItemInput{ + TableName: input.TableName, + ConditionExpression: input.ConditionExpression, + ConditionalOperator: toStringPtr(string(input.ConditionalOperator)), + ExpressionAttributeNames: input.ExpressionAttributeNames, + ExpressionAttributeValues: mapAttributeValueMapToTypes(input.ExpressionAttributeValues), + Key: mapAttributeValueMapToTypes(input.Key), + UpdateExpression: aws.ToString(input.UpdateExpression), + ReturnValuesOnConditionCheckFailure: toStringPtr(string(input.ReturnValuesOnConditionCheckFailure)), + }) + if err != nil { + return nil, mapKnownError(err) + } + + return &UpdateItemOutput{Attributes: mapTypesMapToAttributeValue(item)}, nil +} + +// GetItem returns a single item by key. +func (c *Client) GetItem(ctx context.Context, input *GetItemInput) (*GetItemOutput, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.forceFailureErr != nil { + return nil, c.forceFailureErr + } + + table, err := c.getTable(aws.ToString(input.TableName)) + if err != nil { + return nil, err + } + + key, err := table.KeySchema.GetKey(table.AttributesDef, mapAttributeValueMapToTypes(input.Key)) + if err != nil { + return nil, &smithy.GenericAPIError{Code: "ValidationException", Message: err.Error()} + } + + return &GetItemOutput{Item: mapTypesMapToAttributeValue(table.Data[key])}, nil +} + +// Query searches items by key condition and optional filter. +func (c *Client) Query(ctx context.Context, input *QueryInput) (*QueryOutput, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.forceFailureErr != nil { + return nil, c.forceFailureErr + } + + table, err := c.getTable(aws.ToString(input.TableName)) + if err != nil { + return nil, err + } + + if input.ScanIndexForward == nil { + input.ScanIndexForward = aws.Bool(true) + } + + items, last := table.SearchData(core.QueryInput{ + Index: aws.ToString(input.IndexName), + ExpressionAttributeValues: mapAttributeValueMapToTypes(input.ExpressionAttributeValues), + Aliases: input.ExpressionAttributeNames, + ExclusiveStartKey: mapAttributeValueMapToTypes(input.ExclusiveStartKey), + KeyConditionExpression: aws.ToString(input.KeyConditionExpression), + FilterExpression: aws.ToString(input.FilterExpression), + Limit: int64(aws.ToInt32(input.Limit)), + ScanIndexForward: aws.ToBool(input.ScanIndexForward), + }) + + count := int32(len(items)) + + return &QueryOutput{ + Items: mapTypesSliceToAttributeValue(items), + Count: count, + LastEvaluatedKey: mapTypesMapToAttributeValue(last), + }, nil +} + +// Scan iterates items (optionally filtered) and returns a page of results. +func (c *Client) Scan(ctx context.Context, input *ScanInput) (*ScanOutput, error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.forceFailureErr != nil { + return nil, c.forceFailureErr + } + + table, err := c.getTable(aws.ToString(input.TableName)) + if err != nil { + return nil, err + } + + items, last := table.SearchData(core.QueryInput{ + Index: aws.ToString(input.IndexName), + ExpressionAttributeValues: mapAttributeValueMapToTypes(input.ExpressionAttributeValues), + Aliases: input.ExpressionAttributeNames, + ExclusiveStartKey: mapAttributeValueMapToTypes(input.ExclusiveStartKey), + FilterExpression: aws.ToString(input.FilterExpression), + Limit: int64(aws.ToInt32(input.Limit)), + Scan: true, + ScanIndexForward: true, + }) + + count := int32(len(items)) + + return &ScanOutput{ + Items: mapTypesSliceToAttributeValue(items), + Count: count, + LastEvaluatedKey: mapTypesMapToAttributeValue(last), + }, nil +} + +// BatchWriteItem handles batch put/delete requests and returns unprocessed items on retriable errors. +// +//gocyclo:ignore +//gocognit:ignore +func (c *Client) BatchWriteItem(ctx context.Context, input *BatchWriteItemInput) (*BatchWriteItemOutput, error) { + if c.forceFailureErr != nil { + return nil, c.forceFailureErr + } + + unprocessed := map[string][]WriteRequest{} + + for tableName, reqs := range input.RequestItems { + for _, req := range reqs { + var err error + if req.PutRequest != nil { + _, err = c.PutItem(ctx, &PutItemInput{ + TableName: aws.String(tableName), + Item: req.PutRequest.Item, + }) + } + + if req.DeleteRequest != nil { + _, err = c.DeleteItem(ctx, &DeleteItemInput{ + TableName: aws.String(tableName), + Key: req.DeleteRequest.Key, + }) + } + + if err != nil { + var oe smithy.APIError + if errors.As(err, &oe) { + var is500 *ddbtypes.InternalServerError + var isThrottled *ddbtypes.ProvisionedThroughputExceededException + + if errors.As(err, &is500) || errors.As(err, &isThrottled) { + unprocessed[tableName] = append(unprocessed[tableName], req) + continue + } + } + + return nil, err + } + } + } + + return &BatchWriteItemOutput{UnprocessedItems: unprocessed}, nil +} + +// Utilities +func mapTypesSliceToAttributeValue(items []map[string]*types.Item) []map[string]*AttributeValue { + if len(items) == 0 { + return nil + } + + out := make([]map[string]*AttributeValue, len(items)) + for i, it := range items { + out[i] = mapTypesMapToAttributeValue(it) + } + + return out +} + +func mapKnownError(err error) error { + if err == nil { + return nil + } + + var intErr types.Error + if !errors.As(err, &intErr) { + return err + } + + switch intErr.Code() { + case "ConditionalCheckFailedException": + checkErr := &ddbtypes.ConditionalCheckFailedException{ + Message: aws.String(intErr.Message()), + } + + var conditionalErr *types.ConditionalCheckFailedException + if errors.As(err, &conditionalErr) { + checkErr.Item = mapTypesMapToDDBAttributeValue(conditionalErr.Item) + } + + return checkErr + case "ResourceNotFoundException": + return &ddbtypes.ResourceNotFoundException{Message: aws.String(intErr.Message())} + default: + return &smithy.GenericAPIError{Code: intErr.Code(), Message: intErr.Message()} + } +} + +func toStringPtrMap(in map[string]string) map[string]*string { + if len(in) == 0 { + return nil + } + + out := make(map[string]*string, len(in)) + + for k, v := range in { + out[k] = aws.String(v) + } + + return out +} diff --git a/server/mapper.go b/server/mapper.go new file mode 100644 index 0000000..b45c429 --- /dev/null +++ b/server/mapper.go @@ -0,0 +1,408 @@ +package server + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/truora/minidyn/types" +) + +// AttributeValue (JSON) -> types.Item +func mapAttributeValueToTypes(av *AttributeValue) *types.Item { + if av == nil { + return nil + } + + return &types.Item{ + B: av.B, + BOOL: av.BOOL, + BS: av.BS, + L: mapAttributeValueListToTypes(av.L), + M: mapAttributeValueMapToTypes(av.M), + N: av.N, + NS: av.NS, + NULL: av.NULL, + S: av.S, + SS: av.SS, + } +} + +func mapAttributeValueListToTypes(list []*AttributeValue) []*types.Item { + if list == nil { + return nil + } + + out := make([]*types.Item, len(list)) + for i, v := range list { + out[i] = mapAttributeValueToTypes(v) + } + + return out +} + +func mapAttributeValueMapToTypes(m map[string]*AttributeValue) map[string]*types.Item { + if m == nil { + return nil + } + + out := make(map[string]*types.Item, len(m)) + for k, v := range m { + out[k] = mapAttributeValueToTypes(v) + } + + return out +} + +// types.Item -> AttributeValue (JSON) +func mapTypesToAttributeValue(it *types.Item) *AttributeValue { + if it == nil { + return nil + } + + return &AttributeValue{ + B: it.B, + BOOL: it.BOOL, + BS: it.BS, + L: mapTypesListToAttributeValue(it.L), + M: mapTypesMapToAttributeValue(it.M), + N: it.N, + NS: it.NS, + NULL: it.NULL, + S: it.S, + SS: it.SS, + } +} + +func mapTypesListToAttributeValue(list []*types.Item) []*AttributeValue { + if list == nil { + return nil + } + + out := make([]*AttributeValue, len(list)) + for i, v := range list { + out[i] = mapTypesToAttributeValue(v) + } + + return out +} + +func mapTypesMapToAttributeValue(m map[string]*types.Item) map[string]*AttributeValue { + if m == nil { + return nil + } + + out := make(map[string]*AttributeValue, len(m)) + for k, v := range m { + out[k] = mapTypesToAttributeValue(v) + } + + return out +} + +// map types.Item to dynamodb AttributeValue interfaces (for smithy errors). +// +//nolint:gocyclo // mapping all shapes in a single switch for readability +func mapTypesToDDBAttributeValue(it *types.Item) ddbtypes.AttributeValue { + if it == nil { + return nil + } + + switch { + case len(it.B) != 0: + return &ddbtypes.AttributeValueMemberB{Value: it.B} + case it.BOOL != nil: + return &ddbtypes.AttributeValueMemberBOOL{Value: *it.BOOL} + case len(it.BS) != 0: + return &ddbtypes.AttributeValueMemberBS{Value: it.BS} + case it.N != nil: + return &ddbtypes.AttributeValueMemberN{Value: aws.ToString(it.N)} + case len(it.NS) != 0: + return &ddbtypes.AttributeValueMemberNS{Value: fromStringPtrs(it.NS)} + case it.S != nil: + return &ddbtypes.AttributeValueMemberS{Value: aws.ToString(it.S)} + case len(it.SS) != 0: + return &ddbtypes.AttributeValueMemberSS{Value: fromStringPtrs(it.SS)} + case len(it.L) != 0: + lv := make([]ddbtypes.AttributeValue, len(it.L)) + for i, v := range it.L { + lv[i] = mapTypesToDDBAttributeValue(v) + } + + return &ddbtypes.AttributeValueMemberL{Value: lv} + case len(it.M) != 0: + mv := make(map[string]ddbtypes.AttributeValue, len(it.M)) + for k, v := range it.M { + mv[k] = mapTypesToDDBAttributeValue(v) + } + + return &ddbtypes.AttributeValueMemberM{Value: mv} + default: + return &ddbtypes.AttributeValueMemberNULL{Value: true} + } +} + +func mapTypesMapToDDBAttributeValue(m map[string]*types.Item) map[string]ddbtypes.AttributeValue { + if len(m) == 0 { + return nil + } + + out := make(map[string]ddbtypes.AttributeValue, len(m)) + for k, v := range m { + out[k] = mapTypesToDDBAttributeValue(v) + } + + return out +} + +// Table / schema helpers (ddbtypes -> types) + +func mapAttributeDefinitions(input []ddbtypes.AttributeDefinition) []*types.AttributeDefinition { + if len(input) == 0 { + return nil + } + + out := make([]*types.AttributeDefinition, len(input)) + for i, a := range input { + out[i] = &types.AttributeDefinition{ + AttributeName: a.AttributeName, + AttributeType: aws.String(string(a.AttributeType)), + } + } + + return out +} + +func mapKeySchema(input []ddbtypes.KeySchemaElement) []*types.KeySchemaElement { + if len(input) == 0 { + return nil + } + + out := make([]*types.KeySchemaElement, len(input)) + for i, ks := range input { + out[i] = &types.KeySchemaElement{ + AttributeName: aws.ToString(ks.AttributeName), + KeyType: string(ks.KeyType), + } + } + + return out +} + +func mapProjection(input *ddbtypes.Projection) *types.Projection { + if input == nil { + return nil + } + + return &types.Projection{ + NonKeyAttributes: toStringPtrs(input.NonKeyAttributes), + ProjectionType: toStringPtr(string(input.ProjectionType)), + } +} + +func mapProvisionedThroughput(input *ddbtypes.ProvisionedThroughput) *types.ProvisionedThroughput { + if input == nil { + return nil + } + + return &types.ProvisionedThroughput{ + ReadCapacityUnits: aws.ToInt64(input.ReadCapacityUnits), + WriteCapacityUnits: aws.ToInt64(input.WriteCapacityUnits), + } +} + +func mapGSI(input []ddbtypes.GlobalSecondaryIndex) []*types.GlobalSecondaryIndex { + if len(input) == 0 { + return nil + } + + out := make([]*types.GlobalSecondaryIndex, len(input)) + for i, g := range input { + out[i] = &types.GlobalSecondaryIndex{ + IndexName: g.IndexName, + KeySchema: mapKeySchema(g.KeySchema), + Projection: mapProjection(g.Projection), + ProvisionedThroughput: mapProvisionedThroughput(g.ProvisionedThroughput), + } + } + + return out +} + +func mapLSI(input []ddbtypes.LocalSecondaryIndex) []*types.LocalSecondaryIndex { + if len(input) == 0 { + return nil + } + + out := make([]*types.LocalSecondaryIndex, len(input)) + for i, l := range input { + out[i] = &types.LocalSecondaryIndex{ + IndexName: l.IndexName, + KeySchema: mapKeySchema(l.KeySchema), + Projection: mapProjection(l.Projection), + } + } + + return out +} + +func mapGSIUpdate(input []ddbtypes.GlobalSecondaryIndexUpdate) []*types.GlobalSecondaryIndexUpdate { + if len(input) == 0 { + return nil + } + + out := make([]*types.GlobalSecondaryIndexUpdate, len(input)) + for i, u := range input { + out[i] = &types.GlobalSecondaryIndexUpdate{ + Create: mapCreateGSI(u.Create), + Delete: mapDeleteGSI(u.Delete), + Update: mapUpdateGSI(u.Update), + } + } + + return out +} + +func mapCreateGSI(input *ddbtypes.CreateGlobalSecondaryIndexAction) *types.CreateGlobalSecondaryIndexAction { + if input == nil { + return nil + } + + return &types.CreateGlobalSecondaryIndexAction{ + IndexName: input.IndexName, + KeySchema: mapKeySchema(input.KeySchema), + Projection: mapProjection(input.Projection), + ProvisionedThroughput: mapProvisionedThroughput(input.ProvisionedThroughput), + } +} + +func mapDeleteGSI(input *ddbtypes.DeleteGlobalSecondaryIndexAction) *types.DeleteGlobalSecondaryIndexAction { + if input == nil { + return nil + } + + return &types.DeleteGlobalSecondaryIndexAction{ + IndexName: input.IndexName, + } +} + +func mapUpdateGSI(input *ddbtypes.UpdateGlobalSecondaryIndexAction) *types.UpdateGlobalSecondaryIndexAction { + if input == nil { + return nil + } + + return &types.UpdateGlobalSecondaryIndexAction{ + IndexName: input.IndexName, + ProvisionedThroughput: mapProvisionedThroughput(input.ProvisionedThroughput), + } +} + +func toStringPtr(s string) *string { + if s == "" { + return nil + } + + return aws.String(s) +} + +func toStringPtrs(in []string) []*string { + if len(in) == 0 { + return nil + } + + out := make([]*string, len(in)) + for i, v := range in { + out[i] = toStringPtr(v) + } + + return out +} + +// Map types.TableDescription to ddbtypes.TableDescription for responses. +func mapTableDescriptionToDDB(td *types.TableDescription) *ddbtypes.TableDescription { + if td == nil { + return nil + } + + return &ddbtypes.TableDescription{ + TableName: aws.String(td.TableName), + ItemCount: aws.Int64(td.ItemCount), + KeySchema: mapTypesKeySchema(td.KeySchema), + GlobalSecondaryIndexes: mapTypesGSI(td.GlobalSecondaryIndexes), + LocalSecondaryIndexes: mapTypesLSI(td.LocalSecondaryIndexes), + } +} + +func mapTypesKeySchema(in []types.KeySchemaElement) []ddbtypes.KeySchemaElement { + if len(in) == 0 { + return nil + } + + out := make([]ddbtypes.KeySchemaElement, len(in)) + for i, ks := range in { + ksCopy := ks + out[i] = ddbtypes.KeySchemaElement{ + AttributeName: &ksCopy.AttributeName, + KeyType: ddbtypes.KeyType(ksCopy.KeyType), + } + } + + return out +} + +func mapTypesGSI(in []types.GlobalSecondaryIndexDescription) []ddbtypes.GlobalSecondaryIndexDescription { + if len(in) == 0 { + return nil + } + + out := make([]ddbtypes.GlobalSecondaryIndexDescription, len(in)) + + for i, g := range in { + gCopy := g + out[i] = ddbtypes.GlobalSecondaryIndexDescription{ + IndexName: gCopy.IndexName, + ItemCount: aws.Int64(gCopy.ItemCount), + KeySchema: mapTypesKeySchema(gCopy.KeySchema), + Projection: &ddbtypes.Projection{ + NonKeyAttributes: fromStringPtrs(gCopy.Projection.NonKeyAttributes), + ProjectionType: ddbtypes.ProjectionType(aws.ToString(gCopy.Projection.ProjectionType)), + }, + } + } + + return out +} + +func mapTypesLSI(in []types.LocalSecondaryIndexDescription) []ddbtypes.LocalSecondaryIndexDescription { + if len(in) == 0 { + return nil + } + + out := make([]ddbtypes.LocalSecondaryIndexDescription, len(in)) + + for i, l := range in { + lCopy := l + out[i] = ddbtypes.LocalSecondaryIndexDescription{ + IndexName: lCopy.IndexName, + ItemCount: aws.Int64(lCopy.ItemCount), + KeySchema: mapTypesKeySchema(lCopy.KeySchema), + Projection: &ddbtypes.Projection{ + NonKeyAttributes: fromStringPtrs(lCopy.Projection.NonKeyAttributes), + ProjectionType: ddbtypes.ProjectionType(aws.ToString(lCopy.Projection.ProjectionType)), + }, + } + } + + return out +} + +func fromStringPtrs(in []*string) []string { + if len(in) == 0 { + return nil + } + + out := make([]string, len(in)) + for i, v := range in { + out[i] = aws.ToString(v) + } + + return out +} diff --git a/server/minidyn.go b/server/minidyn.go new file mode 100644 index 0000000..fa5b367 --- /dev/null +++ b/server/minidyn.go @@ -0,0 +1,41 @@ +package server + +import ( + "errors" + + "github.com/aws/aws-sdk-go-v2/aws" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +// FailureCondition describe the failure condition to emulate. +type FailureCondition string + +const ( + // FailureConditionNone emulates the system working normally. + FailureConditionNone FailureCondition = "none" + // FailureConditionInternalServerError emulates DynamoDB internal error. + FailureConditionInternalServerError FailureCondition = "internal_server" + // FailureConditionDeprecated keeps compatibility with previous forced failure. + FailureConditionDeprecated FailureCondition = "deprecated" +) + +var ( + emulatedInternalServerError = &ddbtypes.InternalServerError{Message: aws.String("emulated error")} + // ErrForcedFailure when the error is forced (deprecated). + ErrForcedFailure = errors.New("forced failure response") + + emulatingErrors = map[FailureCondition]error{ + FailureConditionNone: nil, + FailureConditionInternalServerError: emulatedInternalServerError, + FailureConditionDeprecated: ErrForcedFailure, + } +) + +// EmulateFailure forces the HTTP server to fail on subsequent operations. +func (s *Server) EmulateFailure(condition FailureCondition) { + if s == nil || s.client == nil { + return + } + + s.client.setFailureCondition(emulatingErrors[condition]) +} diff --git a/server/requests.go b/server/requests.go new file mode 100644 index 0000000..449d2c1 --- /dev/null +++ b/server/requests.go @@ -0,0 +1,237 @@ +// Code generated by scripts/generate_requests.go; DO NOT EDIT. + +// Package server exposes request shapes mirrored from DynamoDB inputs. +package server + +import ( + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +type AttributeValueUpdate struct { + Action ddbtypes.AttributeAction `json:"Action,omitempty"` + Value *AttributeValue `json:"Value,omitempty"` +} + +type BatchGetItemInput struct { + RequestItems map[string]KeysAndAttributes `json:"RequestItems,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` +} + +type BatchWriteItemInput struct { + RequestItems map[string][]WriteRequest `json:"RequestItems,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` + ReturnItemCollectionMetrics ddbtypes.ReturnItemCollectionMetrics `json:"ReturnItemCollectionMetrics,omitempty"` +} + +type Condition struct { + ComparisonOperator ddbtypes.ComparisonOperator `json:"ComparisonOperator,omitempty"` + AttributeValueList []*AttributeValue `json:"AttributeValueList,omitempty"` +} + +type ConditionCheck struct { + ConditionExpression *string `json:"ConditionExpression,omitempty"` + Key map[string]*AttributeValue `json:"Key,omitempty"` + TableName *string `json:"TableName,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + ReturnValuesOnConditionCheckFailure ddbtypes.ReturnValuesOnConditionCheckFailure `json:"ReturnValuesOnConditionCheckFailure,omitempty"` +} + +type CreateTableInput struct { + AttributeDefinitions []ddbtypes.AttributeDefinition `json:"AttributeDefinitions,omitempty"` + KeySchema []ddbtypes.KeySchemaElement `json:"KeySchema,omitempty"` + TableName *string `json:"TableName,omitempty"` + BillingMode ddbtypes.BillingMode `json:"BillingMode,omitempty"` + DeletionProtectionEnabled *bool `json:"DeletionProtectionEnabled,omitempty"` + GlobalSecondaryIndexes []ddbtypes.GlobalSecondaryIndex `json:"GlobalSecondaryIndexes,omitempty"` + LocalSecondaryIndexes []ddbtypes.LocalSecondaryIndex `json:"LocalSecondaryIndexes,omitempty"` + ProvisionedThroughput *ddbtypes.ProvisionedThroughput `json:"ProvisionedThroughput,omitempty"` + SSESpecification *ddbtypes.SSESpecification `json:"SSESpecification,omitempty"` + StreamSpecification *ddbtypes.StreamSpecification `json:"StreamSpecification,omitempty"` + TableClass ddbtypes.TableClass `json:"TableClass,omitempty"` + Tags []ddbtypes.Tag `json:"Tags,omitempty"` +} + +type Delete struct { + Key map[string]*AttributeValue `json:"Key,omitempty"` + TableName *string `json:"TableName,omitempty"` + ConditionExpression *string `json:"ConditionExpression,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + ReturnValuesOnConditionCheckFailure ddbtypes.ReturnValuesOnConditionCheckFailure `json:"ReturnValuesOnConditionCheckFailure,omitempty"` +} + +type DeleteItemInput struct { + Key map[string]*AttributeValue `json:"Key,omitempty"` + TableName *string `json:"TableName,omitempty"` + ConditionExpression *string `json:"ConditionExpression,omitempty"` + ConditionalOperator ddbtypes.ConditionalOperator `json:"ConditionalOperator,omitempty"` + Expected map[string]ExpectedAttributeValue `json:"Expected,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` + ReturnItemCollectionMetrics ddbtypes.ReturnItemCollectionMetrics `json:"ReturnItemCollectionMetrics,omitempty"` + ReturnValues ddbtypes.ReturnValue `json:"ReturnValues,omitempty"` + ReturnValuesOnConditionCheckFailure ddbtypes.ReturnValuesOnConditionCheckFailure `json:"ReturnValuesOnConditionCheckFailure,omitempty"` +} + +type DeleteRequest struct { + Key map[string]*AttributeValue `json:"Key,omitempty"` +} + +type DeleteTableInput struct { + TableName *string `json:"TableName,omitempty"` +} + +type DescribeTableInput struct { + TableName *string `json:"TableName,omitempty"` +} + +type ExpectedAttributeValue struct { + AttributeValueList []*AttributeValue `json:"AttributeValueList,omitempty"` + ComparisonOperator ddbtypes.ComparisonOperator `json:"ComparisonOperator,omitempty"` + Exists *bool `json:"Exists,omitempty"` + Value *AttributeValue `json:"Value,omitempty"` +} + +type GetItemInput struct { + Key map[string]*AttributeValue `json:"Key,omitempty"` + TableName *string `json:"TableName,omitempty"` + AttributesToGet []string `json:"AttributesToGet,omitempty"` + ConsistentRead *bool `json:"ConsistentRead,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ProjectionExpression *string `json:"ProjectionExpression,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` +} + +type KeysAndAttributes struct { + Keys []map[string]*AttributeValue `json:"Keys,omitempty"` + AttributesToGet []string `json:"AttributesToGet,omitempty"` + ConsistentRead *bool `json:"ConsistentRead,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ProjectionExpression *string `json:"ProjectionExpression,omitempty"` +} + +type Put struct { + Item map[string]*AttributeValue `json:"Item,omitempty"` + TableName *string `json:"TableName,omitempty"` + ConditionExpression *string `json:"ConditionExpression,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + ReturnValuesOnConditionCheckFailure ddbtypes.ReturnValuesOnConditionCheckFailure `json:"ReturnValuesOnConditionCheckFailure,omitempty"` +} + +type PutItemInput struct { + Item map[string]*AttributeValue `json:"Item,omitempty"` + TableName *string `json:"TableName,omitempty"` + ConditionExpression *string `json:"ConditionExpression,omitempty"` + ConditionalOperator ddbtypes.ConditionalOperator `json:"ConditionalOperator,omitempty"` + Expected map[string]ExpectedAttributeValue `json:"Expected,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` + ReturnItemCollectionMetrics ddbtypes.ReturnItemCollectionMetrics `json:"ReturnItemCollectionMetrics,omitempty"` + ReturnValues ddbtypes.ReturnValue `json:"ReturnValues,omitempty"` + ReturnValuesOnConditionCheckFailure ddbtypes.ReturnValuesOnConditionCheckFailure `json:"ReturnValuesOnConditionCheckFailure,omitempty"` +} + +type PutRequest struct { + Item map[string]*AttributeValue `json:"Item,omitempty"` +} + +type QueryInput struct { + TableName *string `json:"TableName,omitempty"` + AttributesToGet []string `json:"AttributesToGet,omitempty"` + ConditionalOperator ddbtypes.ConditionalOperator `json:"ConditionalOperator,omitempty"` + ConsistentRead *bool `json:"ConsistentRead,omitempty"` + ExclusiveStartKey map[string]*AttributeValue `json:"ExclusiveStartKey,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + FilterExpression *string `json:"FilterExpression,omitempty"` + IndexName *string `json:"IndexName,omitempty"` + KeyConditionExpression *string `json:"KeyConditionExpression,omitempty"` + KeyConditions map[string]Condition `json:"KeyConditions,omitempty"` + Limit *int32 `json:"Limit,omitempty"` + ProjectionExpression *string `json:"ProjectionExpression,omitempty"` + QueryFilter map[string]Condition `json:"QueryFilter,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` + ScanIndexForward *bool `json:"ScanIndexForward,omitempty"` + Select ddbtypes.Select `json:"Select,omitempty"` +} + +type ScanInput struct { + TableName *string `json:"TableName,omitempty"` + AttributesToGet []string `json:"AttributesToGet,omitempty"` + ConditionalOperator ddbtypes.ConditionalOperator `json:"ConditionalOperator,omitempty"` + ConsistentRead *bool `json:"ConsistentRead,omitempty"` + ExclusiveStartKey map[string]*AttributeValue `json:"ExclusiveStartKey,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + FilterExpression *string `json:"FilterExpression,omitempty"` + IndexName *string `json:"IndexName,omitempty"` + Limit *int32 `json:"Limit,omitempty"` + ProjectionExpression *string `json:"ProjectionExpression,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` + ScanFilter map[string]Condition `json:"ScanFilter,omitempty"` + Segment *int32 `json:"Segment,omitempty"` + Select ddbtypes.Select `json:"Select,omitempty"` + TotalSegments *int32 `json:"TotalSegments,omitempty"` +} + +type TransactWriteItem struct { + ConditionCheck *ConditionCheck `json:"ConditionCheck,omitempty"` + Delete *Delete `json:"Delete,omitempty"` + Put *Put `json:"Put,omitempty"` + Update *Update `json:"Update,omitempty"` +} + +type TransactWriteItemsInput struct { + TransactItems []TransactWriteItem `json:"TransactItems,omitempty"` + ClientRequestToken *string `json:"ClientRequestToken,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` + ReturnItemCollectionMetrics ddbtypes.ReturnItemCollectionMetrics `json:"ReturnItemCollectionMetrics,omitempty"` +} + +type Update struct { + Key map[string]*AttributeValue `json:"Key,omitempty"` + TableName *string `json:"TableName,omitempty"` + UpdateExpression *string `json:"UpdateExpression,omitempty"` + ConditionExpression *string `json:"ConditionExpression,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + ReturnValuesOnConditionCheckFailure ddbtypes.ReturnValuesOnConditionCheckFailure `json:"ReturnValuesOnConditionCheckFailure,omitempty"` +} + +type UpdateItemInput struct { + Key map[string]*AttributeValue `json:"Key,omitempty"` + TableName *string `json:"TableName,omitempty"` + AttributeUpdates map[string]AttributeValueUpdate `json:"AttributeUpdates,omitempty"` + ConditionExpression *string `json:"ConditionExpression,omitempty"` + ConditionalOperator ddbtypes.ConditionalOperator `json:"ConditionalOperator,omitempty"` + Expected map[string]ExpectedAttributeValue `json:"Expected,omitempty"` + ExpressionAttributeNames map[string]string `json:"ExpressionAttributeNames,omitempty"` + ExpressionAttributeValues map[string]*AttributeValue `json:"ExpressionAttributeValues,omitempty"` + ReturnConsumedCapacity ddbtypes.ReturnConsumedCapacity `json:"ReturnConsumedCapacity,omitempty"` + ReturnItemCollectionMetrics ddbtypes.ReturnItemCollectionMetrics `json:"ReturnItemCollectionMetrics,omitempty"` + ReturnValues ddbtypes.ReturnValue `json:"ReturnValues,omitempty"` + ReturnValuesOnConditionCheckFailure ddbtypes.ReturnValuesOnConditionCheckFailure `json:"ReturnValuesOnConditionCheckFailure,omitempty"` + UpdateExpression *string `json:"UpdateExpression,omitempty"` +} + +type UpdateTableInput struct { + TableName *string `json:"TableName,omitempty"` + AttributeDefinitions []ddbtypes.AttributeDefinition `json:"AttributeDefinitions,omitempty"` + BillingMode ddbtypes.BillingMode `json:"BillingMode,omitempty"` + DeletionProtectionEnabled *bool `json:"DeletionProtectionEnabled,omitempty"` + GlobalSecondaryIndexUpdates []ddbtypes.GlobalSecondaryIndexUpdate `json:"GlobalSecondaryIndexUpdates,omitempty"` + ProvisionedThroughput *ddbtypes.ProvisionedThroughput `json:"ProvisionedThroughput,omitempty"` + ReplicaUpdates []ddbtypes.ReplicationGroupUpdate `json:"ReplicaUpdates,omitempty"` + SSESpecification *ddbtypes.SSESpecification `json:"SSESpecification,omitempty"` + StreamSpecification *ddbtypes.StreamSpecification `json:"StreamSpecification,omitempty"` + TableClass ddbtypes.TableClass `json:"TableClass,omitempty"` +} + +type WriteRequest struct { + DeleteRequest *DeleteRequest `json:"DeleteRequest,omitempty"` + PutRequest *PutRequest `json:"PutRequest,omitempty"` +} diff --git a/server/responses.go b/server/responses.go new file mode 100644 index 0000000..790210f --- /dev/null +++ b/server/responses.go @@ -0,0 +1,65 @@ +package server + +// Minimal output shapes encoded back to the client. +// They mirror DynamoDB JSON responses closely enough for SDK decoding. + +// CreateTableOutput mirrors DynamoDB CreateTableOutput. +type CreateTableOutput struct { + TableDescription interface{} `json:"TableDescription,omitempty"` +} + +// DeleteTableOutput mirrors DynamoDB DeleteTableOutput. +type DeleteTableOutput struct { + TableDescription interface{} `json:"TableDescription,omitempty"` +} + +// UpdateTableOutput mirrors DynamoDB UpdateTableOutput. +type UpdateTableOutput struct { + TableDescription interface{} `json:"TableDescription,omitempty"` +} + +// DescribeTableOutput mirrors DynamoDB DescribeTableOutput. +type DescribeTableOutput struct { + Table interface{} `json:"Table,omitempty"` +} + +// PutItemOutput mirrors DynamoDB PutItemOutput. +type PutItemOutput struct { + Attributes map[string]*AttributeValue `json:"Attributes,omitempty"` +} + +// DeleteItemOutput mirrors DynamoDB DeleteItemOutput. +type DeleteItemOutput struct { + Attributes map[string]*AttributeValue `json:"Attributes,omitempty"` +} + +// UpdateItemOutput mirrors DynamoDB UpdateItemOutput. +type UpdateItemOutput struct { + Attributes map[string]*AttributeValue `json:"Attributes,omitempty"` +} + +// GetItemOutput mirrors DynamoDB GetItemOutput. +type GetItemOutput struct { + Item map[string]*AttributeValue `json:"Item,omitempty"` +} + +// QueryOutput mirrors DynamoDB QueryOutput. +type QueryOutput struct { + Items []map[string]*AttributeValue `json:"Items,omitempty"` + Count int32 `json:"Count,omitempty"` + ScannedCount int32 `json:"ScannedCount,omitempty"` + LastEvaluatedKey map[string]*AttributeValue `json:"LastEvaluatedKey,omitempty"` +} + +// ScanOutput mirrors DynamoDB ScanOutput. +type ScanOutput struct { + Items []map[string]*AttributeValue `json:"Items,omitempty"` + Count int32 `json:"Count,omitempty"` + ScannedCount int32 `json:"ScannedCount,omitempty"` + LastEvaluatedKey map[string]*AttributeValue `json:"LastEvaluatedKey,omitempty"` +} + +// BatchWriteItemOutput mirrors DynamoDB BatchWriteItemOutput. +type BatchWriteItemOutput struct { + UnprocessedItems map[string][]WriteRequest `json:"UnprocessedItems,omitempty"` +} diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..0a6a7b1 --- /dev/null +++ b/server/server.go @@ -0,0 +1,153 @@ +package server + +import ( + "context" + "encoding/json" + "log" + "net/http" + "strings" +) + +// Server implements http.Handler for DynamoDB JSON API subset. +type Server struct { + client *Client +} + +// NewServer creates an HTTP handler exposing the DynamoDB-compatible API. +func NewServer() *Server { + return &Server{client: NewClient()} +} + +// ServeHTTP dispatches DynamoDB JSON API requests based on X-Amz-Target. +// +//gocyclo:ignore +//gocognit:ignore +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + defer func() { + err := r.Body.Close() + if err != nil { + log.Printf("error closing body: %v", err) + } + }() + + op := "" + + target := r.Header.Get("X-Amz-Target") + if target != "" { + parts := strings.Split(target, ".") + op = parts[len(parts)-1] + } + + decoder := json.NewDecoder(r.Body) + encoder := json.NewEncoder(w) + encoder.SetEscapeHTML(false) + + var ( + resp interface{} + err error + ) + + switch op { + case "CreateTable": + var input CreateTableInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.CreateTable(context.Background(), &input) + } + case "UpdateTable": + var input UpdateTableInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.UpdateTable(context.Background(), &input) + } + case "DeleteTable": + var input DeleteTableInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.DeleteTable(context.Background(), &input) + } + case "DescribeTable": + var input DescribeTableInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.DescribeTable(context.Background(), &input) + } + case "PutItem": + var input PutItemInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.PutItem(context.Background(), &input) + } + case "DeleteItem": + var input DeleteItemInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.DeleteItem(context.Background(), &input) + } + case "UpdateItem": + var input UpdateItemInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.UpdateItem(context.Background(), &input) + } + case "GetItem": + var input GetItemInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.GetItem(context.Background(), &input) + } + case "Query": + var input QueryInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.Query(context.Background(), &input) + } + case "Scan": + var input ScanInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.Scan(context.Background(), &input) + } + case "BatchWriteItem": + var input BatchWriteItemInput + if err = decoder.Decode(&input); err == nil { + resp, err = s.client.BatchWriteItem(context.Background(), &input) + } + default: + http.Error(w, "unsupported operation", http.StatusBadRequest) + return + } + + if err != nil { + writeError(w, err) + return + } + + w.Header().Set("Content-Type", "application/x-amz-json-1.0") + + if err := encoder.Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func writeError(w http.ResponseWriter, err error) { + type errorBody struct { + Type string `json:"__type"` + Message string `json:"message"` + } + + code := http.StatusBadRequest + msg := err.Error() + typ := "InternalFailure" + + if apiErr, ok := err.(interface { + ErrorCode() string + ErrorMessage() string + }); ok { + typ = apiErr.ErrorCode() + msg = apiErr.ErrorMessage() + } + + if typ == "InternalServerError" { + code = http.StatusInternalServerError + } + + w.Header().Set("Content-Type", "application/x-amz-json-1.0") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(errorBody{Type: typ, Message: msg}) +} diff --git a/server/server_test.go b/server/server_test.go new file mode 100644 index 0000000..4f11d46 --- /dev/null +++ b/server/server_test.go @@ -0,0 +1,633 @@ +package server + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/aws/smithy-go/logging" + "github.com/stretchr/testify/require" +) + +func newTestDynamoClient(t *testing.T, url string) *dynamodb.Client { + t.Helper() + + ctx := context.Background() + resolver := dynamodb.EndpointResolverFromURL(url) + httpClient := &http.Client{ + Transport: &http.Transport{ + DisableKeepAlives: true, // avoid reuse warnings + DisableCompression: true, + MaxIdleConns: 1, + MaxIdleConnsPerHost: 1, + DialContext: (&net.Dialer{ + Timeout: 5 * time.Second, + KeepAlive: 5 * time.Second, + }).DialContext, + }, + } + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion("us-east-1"), + config.WithEndpointResolverWithOptions( + aws.EndpointResolverWithOptionsFunc( + func(service, region string, options ...interface{}) (aws.Endpoint, error) { + if service == dynamodb.ServiceID { + return resolver.ResolveEndpoint(region, dynamodb.EndpointResolverOptions{}) + } + return aws.Endpoint{}, &aws.EndpointNotFoundError{} + }, + ), + ), + config.WithHTTPClient(httpClient), + config.WithLogger(logging.Nop{}), + config.WithClientLogMode(0), + config.WithCredentialsProvider(credentials.StaticCredentialsProvider{ + Value: aws.Credentials{ + AccessKeyID: "test", + SecretAccessKey: "test", + SessionToken: "test", + Source: "test", + }, + }), + config.WithRetryer(func() aws.Retryer { return aws.NopRetryer{} }), + ) + require.NoError(t, err) + + return dynamodb.NewFromConfig(cfg) +} + +func TestServerCRUDWithSDKv2(t *testing.T) { + srv := NewServer() + ts := httptest.NewServer(srv) + defer ts.Close() + + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + // put item + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "25"}, + "n": &ddbtypes.AttributeValueMemberS{Value: "pikachu"}, + }, + }) + require.NoError(t, err) + + // get item + out, err := cli.GetItem(context.Background(), &dynamodb.GetItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "25"}, + }, + }) + require.NoError(t, err) + require.Equal(t, "pikachu", out.Item["n"].(*ddbtypes.AttributeValueMemberS).Value) +} + +func TestServerQueryGSIWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + _, err := cli.CreateTable(context.Background(), &dynamodb.CreateTableInput{ + TableName: aws.String("pokemons"), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String("id"), KeyType: ddbtypes.KeyTypeHash}, + }, + AttributeDefinitions: []ddbtypes.AttributeDefinition{ + {AttributeName: aws.String("id"), AttributeType: ddbtypes.ScalarAttributeTypeS}, + {AttributeName: aws.String("type"), AttributeType: ddbtypes.ScalarAttributeTypeS}, + }, + BillingMode: ddbtypes.BillingModePayPerRequest, + GlobalSecondaryIndexes: []ddbtypes.GlobalSecondaryIndex{ + { + IndexName: aws.String("by-type"), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String("type"), KeyType: ddbtypes.KeyTypeHash}, + {AttributeName: aws.String("id"), KeyType: ddbtypes.KeyTypeRange}, + }, + Projection: &ddbtypes.Projection{ProjectionType: ddbtypes.ProjectionTypeAll}, + }, + }, + }) + require.NoError(t, err) + + put := func(id, typ string) { + _, putErr := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: id}, + "type": &ddbtypes.AttributeValueMemberS{Value: typ}, + }, + }) + require.NoError(t, putErr) + } + put("25", "electric") + put("26", "electric") + + qOut, err := cli.Query(context.Background(), &dynamodb.QueryInput{ + TableName: aws.String("pokemons"), + IndexName: aws.String("by-type"), + ExpressionAttributeNames: map[string]string{ + "#type": "type", + }, + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":type": &ddbtypes.AttributeValueMemberS{Value: "electric"}, + }, + KeyConditionExpression: aws.String("#type = :type"), + }) + require.NoError(t, err) + require.Len(t, qOut.Items, 2) +} + +func TestServerConditionalPutFailsWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + }, + }) + require.NoError(t, err) + + _, err = cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + }, + ConditionExpression: aws.String("attribute_not_exists(id)"), + }) + var condErr *ddbtypes.ConditionalCheckFailedException + require.ErrorAs(t, err, &condErr) +} + +func TestServerBatchWriteWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + _, err := cli.BatchWriteItem(context.Background(), &dynamodb.BatchWriteItemInput{ + RequestItems: map[string][]ddbtypes.WriteRequest{ + "pokemons": { + {PutRequest: &ddbtypes.PutRequest{Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + }}}, + {PutRequest: &ddbtypes.PutRequest{Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "2"}, + }}}, + }, + }, + }) + require.NoError(t, err) + + out, err := cli.GetItem(context.Background(), &dynamodb.GetItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "2"}, + }, + }) + + require.NoError(t, err) + require.Equal(t, "2", out.Item["id"].(*ddbtypes.AttributeValueMemberS).Value) +} + +func TestServerUpdateItemWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + "lvl": &ddbtypes.AttributeValueMemberN{Value: "1"}, + }, + }) + require.NoError(t, err) + + upd, err := cli.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{"id": &ddbtypes.AttributeValueMemberS{Value: "1"}}, + UpdateExpression: aws.String("SET lvl = :n"), + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":n": &ddbtypes.AttributeValueMemberN{Value: "2"}, + }, + ReturnValues: ddbtypes.ReturnValueAllNew, + }) + require.NoError(t, err) + + require.Equal(t, "2", upd.Attributes["lvl"].(*ddbtypes.AttributeValueMemberN).Value) +} + +func TestServerDeleteItemReturnsOldWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + "name": &ddbtypes.AttributeValueMemberS{Value: "pikachu"}, + }, + }) + require.NoError(t, err) + + del, err := cli.DeleteItem(context.Background(), &dynamodb.DeleteItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{"id": &ddbtypes.AttributeValueMemberS{Value: "1"}}, + ReturnValues: ddbtypes.ReturnValueAllOld, + }) + require.NoError(t, err) + require.Equal(t, "pikachu", del.Attributes["name"].(*ddbtypes.AttributeValueMemberS).Value) +} + +func TestServerScanWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + for i := 0; i < 3; i++ { + id := aws.String(fmt.Sprintf("%d", i)) + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: *id}, + }, + }) + require.NoError(t, err) + } + + scan, err := cli.Scan(context.Background(), &dynamodb.ScanInput{ + TableName: aws.String("pokemons"), + }) + require.NoError(t, err) + require.Equal(t, int32(3), scan.Count) +} + +func TestServerDescribeAndUpdateTableWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + _, err := cli.DescribeTable(context.Background(), &dynamodb.DescribeTableInput{ + TableName: aws.String("pokemons"), + }) + require.NoError(t, err) + + _, err = cli.UpdateTable(context.Background(), &dynamodb.UpdateTableInput{ + TableName: aws.String("pokemons"), + AttributeDefinitions: []ddbtypes.AttributeDefinition{ + {AttributeName: aws.String("type"), AttributeType: ddbtypes.ScalarAttributeTypeS}, + }, + GlobalSecondaryIndexUpdates: []ddbtypes.GlobalSecondaryIndexUpdate{ + {Create: &ddbtypes.CreateGlobalSecondaryIndexAction{ + IndexName: aws.String("by-type"), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String("type"), KeyType: ddbtypes.KeyTypeHash}, + }, + Projection: &ddbtypes.Projection{ProjectionType: ddbtypes.ProjectionTypeAll}, + }}, + }, + }) + require.NoError(t, err) +} + +func TestServerAddLSIWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + _, err := cli.CreateTable(context.Background(), &dynamodb.CreateTableInput{ + TableName: aws.String("pokemons"), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String("id"), KeyType: ddbtypes.KeyTypeHash}, + {AttributeName: aws.String("ts"), KeyType: ddbtypes.KeyTypeRange}, + }, + AttributeDefinitions: []ddbtypes.AttributeDefinition{ + {AttributeName: aws.String("id"), AttributeType: ddbtypes.ScalarAttributeTypeS}, + {AttributeName: aws.String("ts"), AttributeType: ddbtypes.ScalarAttributeTypeN}, + {AttributeName: aws.String("type"), AttributeType: ddbtypes.ScalarAttributeTypeS}, + }, + BillingMode: ddbtypes.BillingModePayPerRequest, + LocalSecondaryIndexes: []ddbtypes.LocalSecondaryIndex{ + { + IndexName: aws.String("by-type"), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String("id"), KeyType: ddbtypes.KeyTypeHash}, + {AttributeName: aws.String("type"), KeyType: ddbtypes.KeyTypeRange}, + }, + Projection: &ddbtypes.Projection{ProjectionType: ddbtypes.ProjectionTypeAll}, + }, + }, + }) + require.NoError(t, err) +} + +func TestServerDeleteTableWithSDKv2(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + _, err := cli.DeleteTable(context.Background(), &dynamodb.DeleteTableInput{ + TableName: aws.String("pokemons"), + }) + require.NoError(t, err) + + _, err = cli.DescribeTable(context.Background(), &dynamodb.DescribeTableInput{ + TableName: aws.String("pokemons"), + }) + var notFound *ddbtypes.ResourceNotFoundException + require.ErrorAs(t, err, ¬Found) +} + +func TestServerEmulateFailureWithSDKv2(t *testing.T) { + s := NewServer() + + ts := httptest.NewServer(s) + defer ts.Close() + + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + s.EmulateFailure(FailureConditionInternalServerError) + + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + }, + }) + var internal *ddbtypes.InternalServerError + require.ErrorAs(t, err, &internal) + + s.EmulateFailure(FailureConditionNone) + _, err = cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "2"}, + }, + }) + require.NoError(t, err) +} + +func TestServerGetItemAttributeNameValidation(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + "name": &ddbtypes.AttributeValueMemberS{Value: "pikachu"}, + }, + }) + require.NoError(t, err) + + out, err := cli.GetItem(context.Background(), &dynamodb.GetItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + }, + ExpressionAttributeNames: map[string]string{ + "#name-1": "name", + }, + ProjectionExpression: aws.String("#name-1"), + }) + require.NoError(t, err) + require.Equal(t, "pikachu", out.Item["name"].(*ddbtypes.AttributeValueMemberS).Value) +} + +func TestServerPutItemWithConditionsValidation(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + + // first put ok + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + }, + }) + require.NoError(t, err) + + // second with unused expression attribute value + _, err = cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + }, + ConditionExpression: aws.String("attribute_not_exists(#type)"), + ExpressionAttributeNames: map[string]string{ + "#type": "type", + }, + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":not_used": &ddbtypes.AttributeValueMemberNULL{Value: true}, + }, + }) + require.NoError(t, err) +} + +func TestServerUpdateExpressionsAddRemoveDelete(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: "1"}, + "lvl": &ddbtypes.AttributeValueMemberN{Value: "0"}, + "moves": &ddbtypes.AttributeValueMemberSS{Value: []string{"Growl", "Tackle"}}, + "local": &ddbtypes.AttributeValueMemberL{Value: []ddbtypes.AttributeValue{ + &ddbtypes.AttributeValueMemberS{Value: "a"}, + &ddbtypes.AttributeValueMemberS{Value: "b"}, + }}, + }, + }) + require.NoError(t, err) + + // ADD + _, err = cli.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{"id": &ddbtypes.AttributeValueMemberS{Value: "1"}}, + UpdateExpression: aws.String("ADD lvl :one"), + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":one": &ddbtypes.AttributeValueMemberN{Value: "1"}, + }, + }) + require.NoError(t, err) + + // REMOVE + _, err = cli.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{"id": &ddbtypes.AttributeValueMemberS{Value: "1"}}, + UpdateExpression: aws.String("REMOVE #l[0],#l[1]"), + ExpressionAttributeNames: map[string]string{ + "#l": "local", + }, + }) + require.NoError(t, err) + + // DELETE + _, err = cli.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ + TableName: aws.String("pokemons"), + Key: map[string]ddbtypes.AttributeValue{"id": &ddbtypes.AttributeValueMemberS{Value: "1"}}, + UpdateExpression: aws.String("DELETE moves :move"), + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":move": &ddbtypes.AttributeValueMemberSS{Value: []string{"Growl"}}, + }, + }) + require.NoError(t, err) +} + +func TestServerQueryPaginationAndFilters(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + // table + GSI + _, err := cli.CreateTable(context.Background(), &dynamodb.CreateTableInput{ + TableName: aws.String("pokemons"), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String("id"), KeyType: ddbtypes.KeyTypeHash}, + }, + AttributeDefinitions: []ddbtypes.AttributeDefinition{ + {AttributeName: aws.String("id"), AttributeType: ddbtypes.ScalarAttributeTypeS}, + {AttributeName: aws.String("type"), AttributeType: ddbtypes.ScalarAttributeTypeS}, + }, + BillingMode: ddbtypes.BillingModePayPerRequest, + GlobalSecondaryIndexes: []ddbtypes.GlobalSecondaryIndex{ + { + IndexName: aws.String("by-type"), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String("type"), KeyType: ddbtypes.KeyTypeHash}, + {AttributeName: aws.String("id"), KeyType: ddbtypes.KeyTypeRange}, + }, + Projection: &ddbtypes.Projection{ProjectionType: ddbtypes.ProjectionTypeAll}, + }, + }, + }) + require.NoError(t, err) + + put := func(id, typ string) { + _, putErr := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: id}, + "type": &ddbtypes.AttributeValueMemberS{Value: typ}, + "name": &ddbtypes.AttributeValueMemberS{Value: "n-" + id}, + }, + }) + require.NoError(t, putErr) + } + put("001", "grass") + put("002", "grass") + put("003", "grass") + + input := &dynamodb.QueryInput{ + TableName: aws.String("pokemons"), + IndexName: aws.String("by-type"), + KeyConditionExpression: aws.String("#type = :type"), + ExpressionAttributeNames: map[string]string{ + "#type": "type", + }, + ExpressionAttributeValues: map[string]ddbtypes.AttributeValue{ + ":type": &ddbtypes.AttributeValueMemberS{Value: "grass"}, + }, + Limit: aws.Int32(1), + } + out, err := cli.Query(context.Background(), input) + require.NoError(t, err) + require.Len(t, out.Items, 1) + require.NotEmpty(t, out.LastEvaluatedKey) + + input.ExclusiveStartKey = out.LastEvaluatedKey + out, err = cli.Query(context.Background(), input) + require.NoError(t, err) + require.Len(t, out.Items, 1) + + // filter expression reduces results + input.FilterExpression = aws.String("#name = :name") + input.ExpressionAttributeNames["#name"] = "name" + input.ExpressionAttributeValues[":name"] = &ddbtypes.AttributeValueMemberS{Value: "n-003"} + out, err = cli.Query(context.Background(), input) + require.NoError(t, err) + require.Len(t, out.Items, 0) // because start key advanced +} + +func TestServerScanFiltersAndErrors(t *testing.T) { + ts := httptest.NewServer(NewServer()) + defer ts.Close() + cli := newTestDynamoClient(t, ts.URL) + + makeBasicTable(t, cli, "pokemons", "id") + for i := 0; i < 2; i++ { + _, err := cli.PutItem(context.Background(), &dynamodb.PutItemInput{ + TableName: aws.String("pokemons"), + Item: map[string]ddbtypes.AttributeValue{ + "id": &ddbtypes.AttributeValueMemberS{Value: fmt.Sprintf("%d", i)}, + "type": &ddbtypes.AttributeValueMemberS{Value: "grass"}, + }, + }) + require.NoError(t, err) + } + + // scan ok + _, err := cli.Scan(context.Background(), &dynamodb.ScanInput{ + TableName: aws.String("pokemons"), + }) + require.NoError(t, err) +} + +// helpers + +func makeBasicTable(t *testing.T, cli *dynamodb.Client, table, hashKey string) { + t.Helper() + _, err := cli.CreateTable(context.Background(), &dynamodb.CreateTableInput{ + TableName: aws.String(table), + KeySchema: []ddbtypes.KeySchemaElement{ + {AttributeName: aws.String(hashKey), KeyType: ddbtypes.KeyTypeHash}, + }, + AttributeDefinitions: []ddbtypes.AttributeDefinition{ + {AttributeName: aws.String(hashKey), AttributeType: ddbtypes.ScalarAttributeTypeS}, + }, + BillingMode: ddbtypes.BillingModePayPerRequest, + }) + require.NoError(t, err) +} diff --git a/server/types.go b/server/types.go new file mode 100644 index 0000000..9dcf7d9 --- /dev/null +++ b/server/types.go @@ -0,0 +1,16 @@ +package server + +// AttributeValue is a concrete representation of DynamoDB's AttributeValue +// that works with standard json encoding/decoding. +type AttributeValue struct { + B []byte `json:"B,omitempty"` + BOOL *bool `json:"BOOL,omitempty"` + BS [][]byte `json:"BS,omitempty"` + L []*AttributeValue `json:"L,omitempty"` + M map[string]*AttributeValue `json:"M,omitempty"` + N *string `json:"N,omitempty"` + NS []*string `json:"NS,omitempty"` + NULL *bool `json:"NULL,omitempty"` + S *string `json:"S,omitempty"` + SS []*string `json:"SS,omitempty"` +} diff --git a/tools/generate_requests/generate_requests.go b/tools/generate_requests/generate_requests.go new file mode 100644 index 0000000..145a011 --- /dev/null +++ b/tools/generate_requests/generate_requests.go @@ -0,0 +1,255 @@ +// Package main generates request types mirroring DynamoDB SDK inputs. +package main + +import ( + "bytes" + "flag" + "fmt" + "go/format" + "os" + "path/filepath" + "reflect" + "sort" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + ddbtypes "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +// operations we support via HTTP. +var supportedInputs = []reflect.Type{ + reflect.TypeOf(dynamodb.CreateTableInput{}), + reflect.TypeOf(dynamodb.DeleteTableInput{}), + reflect.TypeOf(dynamodb.UpdateTableInput{}), + reflect.TypeOf(dynamodb.DescribeTableInput{}), + reflect.TypeOf(dynamodb.PutItemInput{}), + reflect.TypeOf(dynamodb.DeleteItemInput{}), + reflect.TypeOf(dynamodb.UpdateItemInput{}), + reflect.TypeOf(dynamodb.GetItemInput{}), + reflect.TypeOf(dynamodb.QueryInput{}), + reflect.TypeOf(dynamodb.ScanInput{}), + reflect.TypeOf(dynamodb.BatchWriteItemInput{}), + reflect.TypeOf(dynamodb.BatchGetItemInput{}), + reflect.TypeOf(dynamodb.TransactWriteItemsInput{}), +} + +var attributeValueType = reflect.TypeOf((*ddbtypes.AttributeValue)(nil)).Elem() + +type generated struct { + order []string + decls map[string]string +} + +func main() { + out := flag.String("out", "server/requests.go", "output file path for generated requests") + flag.Parse() + + if err := run(*out); err != nil { + fmt.Fprintf(os.Stderr, "generate failed: %v\n", err) + os.Exit(1) + } +} + +func run(outPath string) error { + gen := generated{ + order: []string{}, + decls: map[string]string{}, + } + + for _, t := range supportedInputs { + generateStruct(&gen, t) + } + + sort.Strings(gen.order) + + var buf bytes.Buffer + buf.WriteString("// Code generated by tools/generate_requests; DO NOT EDIT.\n") + buf.WriteString("package server\n\n") + buf.WriteString("import (\n") + buf.WriteString("\tddb \"github.com/aws/aws-sdk-go-v2/service/dynamodb\"\n") + buf.WriteString("\tddbtypes \"github.com/aws/aws-sdk-go-v2/service/dynamodb/types\"\n") + buf.WriteString(")\n\n") + + for _, name := range gen.order { + buf.WriteString(gen.decls[name]) + buf.WriteString("\n\n") + } + + formatted, err := format.Source(buf.Bytes()) + if err != nil { + return fmt.Errorf("format: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(outPath), 0o750); err != nil { + return fmt.Errorf("mkdir: %w", err) + } + + if err := os.WriteFile(outPath, formatted, 0o600); err != nil { + return fmt.Errorf("write: %w", err) + } + + return nil +} + +func generateStruct(gen *generated, t reflect.Type) { //nolint:gocognit,gocyclo // loops through struct fields only once + name := t.Name() + if name == "" { + return + } + + if _, ok := gen.decls[name]; ok { + return + } + + fields := []string{} + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + + // skip smithy internals and unexported. + if !f.IsExported() || f.Name == "noSmithyDocumentSerde" { + continue + } + + fieldType, helperNames := renderType(gen, f.Type) + for _, hn := range helperNames { + if _, ok := gen.decls[hn]; !ok { + // dependency handled in renderType + _ = hn + } + } + + tag := string(f.Tag) + + jsonTag := fmt.Sprintf("`json:\"%s,omitempty\"`", f.Name) + if tag == "" { + tag = jsonTag + } else if !strings.Contains(tag, "json:\"") { + tag = strings.TrimSpace(tag + " " + jsonTag) + } + + fields = append(fields, fmt.Sprintf("\t%s %s %s", f.Name, fieldType, tag)) + } + + var b bytes.Buffer + fmt.Fprintf(&b, "type %s struct {\n", name) + + for _, f := range fields { + b.WriteString(f) + b.WriteString("\n") + } + + b.WriteString("}\n") + + gen.order = append(gen.order, name) + gen.decls[name] = b.String() +} + +func renderType(gen *generated, t reflect.Type) (string, []string) { //nolint:gocognit,gocyclo // switch enumerates all type cases + if t == attributeValueType { + return "*AttributeValue", nil + } + + // handle named types from dynamodb packages (including enums) early. + if t.PkgPath() == "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" { + if t.Kind() == reflect.Struct && needsGeneration(t) { + generateStruct(gen, t) + return t.Name(), []string{t.Name()} + } + + return "ddbtypes." + t.Name(), nil + } + + if t.PkgPath() == "github.com/aws/aws-sdk-go-v2/service/dynamodb" { + if t.Kind() == reflect.Struct && needsGeneration(t) { + generateStruct(gen, t) + return t.Name(), []string{t.Name()} + } + + return "ddb." + t.Name(), nil + } + + switch t.Kind() { + case reflect.Pointer: + inner, helpers := renderType(gen, t.Elem()) + return "*" + inner, helpers + case reflect.Slice: + inner, helpers := renderType(gen, t.Elem()) + return "[]" + inner, helpers + case reflect.Map: + key, hk := renderType(gen, t.Key()) + val, hv := renderType(gen, t.Elem()) + + helpers := append([]string{}, hk...) + helpers = append(helpers, hv...) + + return fmt.Sprintf("map[%s]%s", key, val), helpers + case reflect.Interface: + // Replace AttributeValue interfaces with our concrete struct. + if t == attributeValueType { + return "*AttributeValue", nil + } + + return t.String(), nil + case reflect.Struct: + if t.PkgPath() == "github.com/aws/aws-sdk-go-v2/service/dynamodb" || t.PkgPath() == "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" { + if t == reflect.TypeOf(ddbtypes.AttributeValueMemberNULL{}) { + return "ddbtypes.AttributeValueMemberNULL", nil + } + + if needsGeneration(t) { + generateStruct(gen, t) + return t.Name(), []string{t.Name()} + } + + // use fully-qualified name for pass-through structs + pkgAlias := "ddb" + if t.PkgPath() == "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" { + pkgAlias = "ddbtypes" + } + + return pkgAlias + "." + t.Name(), nil + } + + return t.String(), nil + default: + return t.String(), nil + } +} + +func needsGeneration(t reflect.Type) bool { + if t.Kind() != reflect.Struct { + return false + } + + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + + if !f.IsExported() { + continue + } + + if containsAttributeValue(f.Type) { + return true + } + } + + return false +} + +func containsAttributeValue(t reflect.Type) bool { + switch t.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Map: + return containsAttributeValue(t.Elem()) + case reflect.Interface: + return t == attributeValueType + case reflect.Struct: + for i := 0; i < t.NumField(); i++ { + if containsAttributeValue(t.Field(i).Type) { + return true + } + } + } + + return false +} diff --git a/tools/generate_requests/generate_requests_test.go b/tools/generate_requests/generate_requests_test.go new file mode 100644 index 0000000..9a72d56 --- /dev/null +++ b/tools/generate_requests/generate_requests_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunGeneratesRequestsFile(t *testing.T) { + tmp := t.TempDir() + out := filepath.Join(tmp, "requests.go") + + if err := run(out); err != nil { + t.Fatalf("run() error = %v", err) + } + + data, err := os.ReadFile(out) //nolint:gosec // test reads known temp file + if err != nil { + t.Fatalf("read output: %v", err) + } + + content := string(data) + if !strings.Contains(content, "Code generated by tools/generate_requests") { + t.Fatalf("missing header in output") + } + if !strings.Contains(content, "type PutItemInput") { + t.Fatalf("missing PutItemInput in output") + } + if !strings.Contains(content, "*AttributeValue") { + t.Fatalf("expected concrete AttributeValue usage") + } +} diff --git a/types/errors.go b/types/errors.go index 1002ab6..37a29b8 100644 --- a/types/errors.go +++ b/types/errors.go @@ -1,6 +1,7 @@ package types import ( + "errors" "fmt" ) @@ -132,7 +133,8 @@ func (b baseError) OrigErr() error { case 1: return b.errs[0] default: - if err, ok := b.errs[0].(Error); ok { + var err Error + if errors.As(b.errs[0], &err) { return NewBatchError(err.Code(), err.Message(), b.errs[1:]) } @@ -156,7 +158,7 @@ type requestError struct { awsError statusCode int requestID string - bytes []byte // nolint:unused + bytes []byte // nolint:unused // retained to match SDK interfaces } // newRequestError returns a wrapped error with additional information for @@ -174,6 +176,7 @@ func newRequestError(err Error, statusCode int, requestID string) *requestError func (r requestError) Error() string { extra := fmt.Sprintf("status code: %d, request id: %s", r.statusCode, r.requestID) + return SprintError(r.Code(), r.Message(), extra, r.OrigErr()) } diff --git a/types/errors_test.go b/types/errors_test.go new file mode 100644 index 0000000..aea1548 --- /dev/null +++ b/types/errors_test.go @@ -0,0 +1,69 @@ +//revive:disable-next-line var-naming // keep same package for white-box testing +package types + +import ( + "errors" + "strings" + "testing" +) + +func TestNewErrorWrapsCodeMessageAndOrig(t *testing.T) { + orig := errors.New("boom") + err := NewError("BadRequest", "something failed", orig) + + if err.Code() != "BadRequest" { + t.Fatalf("expected code BadRequest, got %s", err.Code()) + } + if err.Message() != "something failed" { + t.Fatalf("expected message, got %s", err.Message()) + } + if !errors.Is(err.OrigErr(), orig) { + t.Fatalf("expected orig error") + } + + // Error string should include code/message and orig cause + if got := err.Error(); got == "" || !containsAll(got, []string{"BadRequest", "something failed", "boom"}) { + t.Fatalf("error string missing parts: %s", got) + } +} + +func TestNewBatchErrorWrapsMultiple(t *testing.T) { + errs := []error{errors.New("a"), errors.New("b")} + be := NewBatchError("BatchedErrors", "multiple", errs) + + if len(be.OrigErrs()) != 2 { + t.Fatalf("expected 2 orig errs, got %d", len(be.OrigErrs())) + } +} + +func TestNewRequestFailure(t *testing.T) { + base := NewError("BadRequest", "bad", nil) + rf := NewRequestFailure(base, 400, "req-1") + + if rf.StatusCode() != 400 { + t.Fatalf("expected status 400, got %d", rf.StatusCode()) + } + if rf.RequestID() != "req-1" { + t.Fatalf("expected req id, got %s", rf.RequestID()) + } + if got := rf.Error(); got == "" || !containsAll(got, []string{"BadRequest", "bad", "status code", "req-1"}) { + t.Fatalf("error string missing parts: %s", got) + } +} + +func TestErrorListFormatting(t *testing.T) { + el := errorList{errors.New("one"), errors.New("two")} + s := el.Error() + if s != "one\ntwo" { + t.Fatalf("unexpected format: %q", s) + } +} + +func containsAll(s string, parts []string) bool { + for _, p := range parts { + if !strings.Contains(s, p) { + return false + } + } + return true +}