diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..0caab8e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,37 @@ +name: ci + +on: + push: + branches: + - main + - feature/* + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + + services: + postgres: + image: amazon/dynamodb-local:latest + ports: + - 8000:8000 + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Go + uses: actions/setup-go@v2 + with: + go-version: 1.17 + - name: Configure + run: | + git config --global url."https://${{ secrets.GO_MODULES_TOKEN }}:x-oauth-basic@github.com/lmika".insteadOf "https://github.com/lmika" + - name: Test + run: | + set -xue + go get ./... + go test ./... + env: + GOPRIVATE: "github:com/lmika/*" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b14c548 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +debug.log diff --git a/cmd/dynamo-browse/main.go b/cmd/dynamo-browse/main.go new file mode 100644 index 0000000..e19cca8 --- /dev/null +++ b/cmd/dynamo-browse/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/commandctrl" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/controllers" + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/internal/dynamo-browse/ui" + "github.com/lmika/gopkgs/cli" +) + +func main() { + var flagTable = flag.String("t", "", "dynamodb table name") + var flagLocal = flag.Bool("local", false, "local endpoint") + flag.Parse() + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + cli.Fatalf("cannot load AWS config: %v", err) + } + + var dynamoClient *dynamodb.Client + if *flagLocal { + dynamoClient = dynamodb.NewFromConfig(cfg, + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + } else { + dynamoClient = dynamodb.NewFromConfig(cfg) + } + + dynamoProvider := dynamo.NewProvider(dynamoClient) + + tableService := tables.NewService(dynamoProvider) + + loopback := &msgLoopback{} + uiDispatcher := dispatcher.NewDispatcher(loopback) + + tableReadController := controllers.NewTableReadController(tableService, *flagTable) + tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, *flagTable) + + commandController := commandctrl.NewCommandController(map[string]uimodels.Operation{ + "scan": tableReadController.Scan(), + "rw": tableWriteController.ToggleReadWrite(), + "dup": tableWriteController.Duplicate(), + }) + + uiModel := ui.NewModel(uiDispatcher, commandController, tableReadController, tableWriteController) + p := tea.NewProgram(uiModel, tea.WithAltScreen()) + loopback.program = p + + f, err := tea.LogToFile("debug.log", "debug") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + defer f.Close() + + if err := p.Start(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} + +type msgLoopback struct { + program *tea.Program +} + +func (m *msgLoopback) Send(msg tea.Msg) { + m.program.Send(msg) +} diff --git a/cmd/sqs-browse/main.go b/cmd/sqs-browse/main.go new file mode 100644 index 0000000..edb761f --- /dev/null +++ b/cmd/sqs-browse/main.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/sqs" + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/sqs-browse/controllers" + "github.com/lmika/awstools/internal/sqs-browse/models" + sqsprovider "github.com/lmika/awstools/internal/sqs-browse/providers/sqs" + "github.com/lmika/awstools/internal/sqs-browse/providers/stormstore" + "github.com/lmika/awstools/internal/sqs-browse/services/messages" + "github.com/lmika/awstools/internal/sqs-browse/services/pollmessage" + "github.com/lmika/awstools/internal/sqs-browse/ui" + "github.com/lmika/events" + "github.com/lmika/gopkgs/cli" +) + +func main() { + var flagQueue = flag.String("q", "", "queue to poll") + var flagTarget = flag.String("t", "", "target queue to push to") + flag.Parse() + + ctx := context.Background() + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + cli.Fatalf("cannot load AWS config: %v", err) + } + sqsClient := sqs.NewFromConfig(cfg) + + bus := events.New() + + workspaceFile, err := os.CreateTemp("", "sqs-browse*.workspace") + if err != nil { + cli.Fatalf("cannot create workspace file: %v", err) + } + workspaceFile.Close() // We just need the filename + + msgStore, err := stormstore.NewStore(workspaceFile.Name()) + if err != nil { + cli.Fatalf("cannot open workspace: %v", err) + } + defer msgStore.Close() + + sqsProvider := sqsprovider.NewProvider(sqsClient) + + messageService := messages.NewService(sqsProvider) + pollService := pollmessage.NewService(msgStore, sqsProvider, *flagQueue, bus) + + msgSendingHandlers := controllers.NewMessageSendingController(messageService, *flagTarget) + + loopback := &msgLoopback{} + uiDispatcher := dispatcher.NewDispatcher(loopback) + + uiModel := ui.NewModel(uiDispatcher, msgSendingHandlers) + p := tea.NewProgram(uiModel, tea.WithAltScreen()) + loopback.program = p + + bus.On("new-messages", func(m []*models.Message) { p.Send(ui.NewMessagesEvent(m)) }) + + f, err := tea.LogToFile("debug.log", "debug") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + defer f.Close() + + log.Printf("workspace file: %v", workspaceFile.Name()) + + go func() { + if err := pollService.Poll(context.Background()); err != nil { + log.Printf("cannot start poller: %v", err) + } + }() + + if err := p.Start(); err != nil { + fmt.Printf("Alas, there's been an error: %v", err) + os.Exit(1) + } +} + +type msgLoopback struct { + program *tea.Program +} + +func (m *msgLoopback) Send(msg tea.Msg) { + m.program.Send(msg) +} diff --git a/cmd/sqs-drain/main.go b/cmd/sqs-drain/main.go index cc65159..a2efb9d 100644 --- a/cmd/sqs-drain/main.go +++ b/cmd/sqs-drain/main.go @@ -3,15 +3,16 @@ package main import ( "context" "flag" + "log" + "os" + "path/filepath" + "time" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/sqs" "github.com/aws/aws-sdk-go-v2/service/sqs/types" "github.com/pkg/errors" - "log" - "os" - "path/filepath" - "time" "github.com/lmika/gopkgs/cli" ) @@ -44,9 +45,9 @@ func main() { msgCount := 0 for { out, err := client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ - QueueUrl: aws.String(*flagQueue), + QueueUrl: aws.String(*flagQueue), MaxNumberOfMessages: 10, - WaitTimeSeconds: 1, + WaitTimeSeconds: 1, }) if err != nil { log.Fatalf("error receiving messages: %v", err) @@ -59,7 +60,7 @@ func main() { for _, msg := range out.Messages { if err := handleMessage(ctx, outDir, msg); err == nil { messagesToDelete = append(messagesToDelete, types.DeleteMessageBatchRequestEntry{ - Id: msg.MessageId, + Id: msg.MessageId, ReceiptHandle: msg.ReceiptHandle, }) msgCount += 1 @@ -74,7 +75,7 @@ func main() { if _, err := client.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{ QueueUrl: aws.String(*flagQueue), - Entries: messagesToDelete, + Entries: messagesToDelete, }); err != nil { log.Printf("error deleting messages from queue: %v", err) break @@ -85,7 +86,7 @@ func main() { } func handleMessage(ctx context.Context, outDir string, msg types.Message) error { - outFile := filepath.Join(outDir, aws.ToString(msg.MessageId) + ".json") + outFile := filepath.Join(outDir, aws.ToString(msg.MessageId)+".json") msgBody := aws.ToString(msg.Body) log.Printf("%v -> %v", aws.ToString(msg.MessageId), outFile) diff --git a/go.mod b/go.mod index cc2c34a..c06ea6e 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,55 @@ module github.com/lmika/awstools go 1.17 require ( - github.com/aws/aws-sdk-go-v2 v1.13.0 // indirect - github.com/aws/aws-sdk-go-v2/config v1.13.1 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2 v1.15.0 + github.com/aws/aws-sdk-go-v2/config v1.13.1 + github.com/aws/aws-sdk-go-v2/credentials v1.8.0 + github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0 + github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 + github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 + github.com/calyptia/go-bubble-table v0.1.0 + github.com/charmbracelet/bubbles v0.10.3 + github.com/charmbracelet/bubbletea v0.20.0 + github.com/charmbracelet/lipgloss v0.5.0 + github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e + github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 + github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.7.1 +) + +require ( + github.com/alecthomas/participle/v2 v2.0.0-alpha7 // indirect + github.com/asdine/storm v2.1.2+incompatible // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 // indirect + github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 // indirect - github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.9.0 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 // indirect - github.com/aws/smithy-go v1.10.0 // indirect + github.com/aws/smithy-go v1.11.1 // indirect + github.com/brianvoe/gofakeit/v6 v6.15.0 // indirect + github.com/containerd/console v1.0.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc // indirect + github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lunixbochs/vtclean v1.0.0 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + go.etcd.io/bbolt v1.3.6 // indirect + golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect ) diff --git a/go.sum b/go.sum index 70d753c..386946f 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,41 @@ +github.com/alecthomas/participle v0.7.1 h1:2bN7reTw//5f0cugJcTOnY/NYZcWQOaajW+BwZB5xWs= +github.com/alecthomas/participle/v2 v2.0.0-alpha7 h1:cK4vjj0VSgb3lN1nuKA5F7dw+1s1pWBe5bx7nNCnN+c= +github.com/alecthomas/participle/v2 v2.0.0-alpha7/go.mod h1:NumScqsC42o9x+dGj8/YqsIfhrIQjFEOFovxotbBirA= +github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/asdine/storm v2.1.2+incompatible h1:dczuIkyqwY2LrtXPz8ixMrU/OFgZp71kbKTHGrXYt/Q= +github.com/asdine/storm v2.1.2+incompatible/go.mod h1:RarYDc9hq1UPLImuiXK3BIWPJLdIygvV3PsInK0FbVQ= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.13.0 h1:1XIXAfxsEmbhbj5ry3D3vX+6ZcUYvIqSm4CWWEuGZCA= github.com/aws/aws-sdk-go-v2 v1.13.0/go.mod h1:L6+ZpqHaLbAaxsqV0L4cvxZY7QupWJB4fhkf8LXvC7w= +github.com/aws/aws-sdk-go-v2 v1.15.0 h1:f9kWLNfyCzCB43eupDAk3/XgJ2EpgktiySD6leqs0js= +github.com/aws/aws-sdk-go-v2 v1.15.0/go.mod h1:lJYcuZZEHWNIb6ugJjbQY1fykdoobWbOS7kJYb4APoI= github.com/aws/aws-sdk-go-v2/config v1.13.1 h1:yLv8bfNoT4r+UvUKQKqRtdnvuWGMK5a82l4ru9Jvnuo= github.com/aws/aws-sdk-go-v2/config v1.13.1/go.mod h1:Ba5Z4yL/UGbjQUzsiaN378YobhFo0MLfueXGiOsYtEs= github.com/aws/aws-sdk-go-v2/credentials v1.8.0 h1:8Ow0WcyDesGNL0No11jcgb1JAtE+WtubqXjgxau+S0o= github.com/aws/aws-sdk-go-v2/credentials v1.8.0/go.mod h1:gnMo58Vwx3Mu7hj1wpcG8DI0s57c9o42UQ6wgTQT5to= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0 h1:XxTy21xVUkoCZOSGwf+AW22v8aK3eEbYMaGGQ3MbKKk= +github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.8.0/go.mod h1:6WkjzWenkrj3IgLPIPBBz4Qh99jNDF8L4Wj03vfMhAA= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0 h1:NITDuUZO34mqtOwFWZiXo7yAHj7kf+XPE+EiKuCBNUI= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.10.0/go.mod h1:I6/fHT/fH460v09eg2gVrd8B/IqskhNdpcLH0WNO3QI= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4 h1:CRiQJ4E2RhfDdqbie1ZYDo8QtIo75Mk7oTdJSfwJTMQ= github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.4/go.mod h1:XHgQ7Hz2WY2GAn//UXHofLfPXWh+s62MbMOijrg12Lw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6 h1:xiGjGVQsem2cxoIX61uRGy+Jux2s9C/kKbTrWLdrU54= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.6/go.mod h1:SSPEdf9spsFgJyhjrXvawfpyzrXHBCUe+2eQ1CjC1Ak= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0 h1:3ADoioDMOtF4uiK59vCpplpCwugEU+v4ZFD29jDL3RQ= github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.2.0/go.mod h1:BsCSJHx5DnDXIrOcqB8KN1/B+hXLG/bi4Y6Vjcx/x9E= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0 h1:bt3zw79tm209glISdMRCIVRCwvSDXxgAxh5KWe2qHkY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.0/go.mod h1:viTrxhAuejD+LszDahzAE2x40YjYWhMqzHxv2ZiWaME= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5 h1:ixotxbfTCFpqbuwFv/RcZwyzhkxPSYDYEMcj4niB5Uk= github.com/aws/aws-sdk-go-v2/internal/ini v1.3.5/go.mod h1:R3sWUqPcfXSiF/LSFJhjyJmpg9uV6yP2yv3YZZjldVI= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0 h1:qnx+WyIH9/AD+wAxi05WCMNanO236ceqHg6hChCWs3M= +github.com/aws/aws-sdk-go-v2/service/dynamodb v1.15.0/go.mod h1:+Kc1UmbE37ijaAsb3KogW6FR8z0myjX6VtdcCkQEK0k= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0 h1:s71pGCiLqqGRoUWtdJ2j4PazwEpZVwQc16na/4FfXdk= +github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.13.0/go.mod h1:YGzTq/joAih4HRZZtMBWGP4bI8xVucOBQ9RvuanpclA= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0 h1:uhb7moM7VjqIEpWzTpCvceLDSwrWpaleXm39OnVjuLE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.0/go.mod h1:pA2St3Pu2Ldy6fBPY45Azoh1WBG4oS7eIKOd4XN7Meg= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0 h1:6Bc0KHhAyxGe15JUHrK+Udw7KhE5LN+5HKZjQGo4yDI= +github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.7.0/go.mod h1:0nXuX9UrkN4r0PX9TSKfcueGRfsdEYIKG4rjTeJ61X8= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0 h1:4QAOB3KrvI1ApJK14sliGr3Ie2pjyvNypn/lfzDHfUw= github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.7.0/go.mod h1:K/qPe6AP2TGYv4l6n7c88zh9jWBDf6nHhvg1fx/EWfU= github.com/aws/aws-sdk-go-v2/service/sqs v1.16.0 h1:dzWS4r8E9bA0TesHM40FSAtedwpTVCuTsLI8EziSqyk= @@ -22,19 +46,98 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.14.0 h1:ksiDXhvNYg0D2/UFkLejsaz3LqpW github.com/aws/aws-sdk-go-v2/service/sts v1.14.0/go.mod h1:u0xMJKDvvfocRjiozsoZglVNXRG19043xzp3r2ivLIk= github.com/aws/smithy-go v1.10.0 h1:gsoZQMNHnX+PaghNw4ynPsyGP7aUCqx5sY2dlPQsZ0w= github.com/aws/smithy-go v1.10.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E= +github.com/aws/smithy-go v1.11.1 h1:IQ+lPZVkSM3FRtyaDox41R8YS6iwPMYIreejOgPW49g= +github.com/aws/smithy-go v1.11.1/go.mod h1:3xHYmszWVx2c0kIwQeEVf9uSm4fYZt67FBJnwub1bgM= +github.com/brianvoe/gofakeit/v6 v6.15.0 h1:lJPGJZ2/07TRGDazyTzD5b18N3y4tmmJpdhCUw18FlI= +github.com/brianvoe/gofakeit/v6 v6.15.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= +github.com/calyptia/go-bubble-table v0.1.0 h1:mXpaaBlrHGH4K8v5PvM8YqBFT9jlysS1YOycU2u3gEQ= +github.com/calyptia/go-bubble-table v0.1.0/go.mod h1:2nnweuFos+eEIIbgweXvZuX+ROOatsMwB3NHnX/vTC4= +github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho= +github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= +github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= +github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= +github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= +github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= +github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 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.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jmespath/go-jmespath v0.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/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc h1:ZQrgZFsLzkw7o3CoDzsfBhx0bf/1rVBXrLy8dXKRe8o= +github.com/juju/ansiterm v0.0.0-20210929141451-8b71cc96ebdc/go.mod h1:PyXUpnI3olx3bsPcHt98FGPX/KCFZ1Fi+hw1XLI6384= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGylMU1b+XnZponQKiPVNi+C/xgA= +github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e/go.mod h1:qtkBmNC9OfD0STtOR9sF55pQchjIfNlC3gzm4n8CrqM= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890 h1:mwl/exYV/WkBMeShqK7q+B2w2r+b0vP1TSA7clBn9kI= github.com/lmika/gopkgs v0.0.0-20211210041137-0dc91e939890/go.mod h1:FH6OJSvYcJ9xY8CGs9yGgR89kMCK1UimuUQ6kE5YuJQ= +github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe h1:1UXS/6OFkbi6JrihPykmYO1VtsABB02QQ+YmYYzTY18= +github.com/lmika/shellwords v0.0.0-20140714114018-ce258dd729fe/go.mod h1:qpdOkLougV5Yry4Px9f1w1pNMavcr6Z67VW5Ro+vW5I= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lunixbochs/vtclean v1.0.0 h1:xu2sLAri4lGiovBDQKxl5mrXyESr3gUr5m5SM5+LVb8= +github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= +github.com/mattn/go-colorable v0.1.10/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= +go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 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= diff --git a/internal/common/ui/commandctrl/commandctrl.go b/internal/common/ui/commandctrl/commandctrl.go new file mode 100644 index 0000000..0d827a9 --- /dev/null +++ b/internal/common/ui/commandctrl/commandctrl.go @@ -0,0 +1,49 @@ +package commandctrl + +import ( + "context" + "strings" + + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/shellwords" + "github.com/pkg/errors" +) + +type CommandController struct { + commands map[string]uimodels.Operation +} + +func NewCommandController(commands map[string]uimodels.Operation) *CommandController { + return &CommandController{ + commands: commands, + } +} + +func (c *CommandController) Prompt() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + uiCtx.Send(events.PromptForInput{ + Prompt: ":", + OnDone: c.Execute(), + }) + return nil + }) +} + +func (c *CommandController) Execute() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + input := strings.TrimSpace(uimodels.PromptValue(ctx)) + if input == "" { + return nil + } + + tokens := shellwords.Split(input) + command, ok := c.commands[tokens[0]] + if !ok { + return errors.New("no such command: " + tokens[0]) + } + + return command.Execute(WithCommandArgs(ctx, tokens[1:])) + }) +} diff --git a/internal/common/ui/commandctrl/commandctrl_test.go b/internal/common/ui/commandctrl/commandctrl_test.go new file mode 100644 index 0000000..93c4c26 --- /dev/null +++ b/internal/common/ui/commandctrl/commandctrl_test.go @@ -0,0 +1,26 @@ +package commandctrl_test + +import ( + "context" + "testing" + + "github.com/lmika/awstools/internal/common/ui/commandctrl" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/test/testuictx" + "github.com/stretchr/testify/assert" +) + +func TestCommandController_Prompt(t *testing.T) { + t.Run("prompt user for a command", func(t *testing.T) { + cmd := commandctrl.NewCommandController(nil) + + ctx, uiCtx := testuictx.New(context.Background()) + err := cmd.Prompt().Execute(ctx) + + assert.NoError(t, err) + + promptMsg, ok := uiCtx.Messages[0].(events.PromptForInput) + assert.True(t, ok) + assert.Equal(t, ":", promptMsg.Prompt) + }) +} diff --git a/internal/common/ui/commandctrl/context.go b/internal/common/ui/commandctrl/context.go new file mode 100644 index 0000000..5417c97 --- /dev/null +++ b/internal/common/ui/commandctrl/context.go @@ -0,0 +1,16 @@ +package commandctrl + +import "context" + +type commandArgContextKeyType struct{} + +var commandArgContextKey = commandArgContextKeyType{} + +func WithCommandArgs(ctx context.Context, args []string) context.Context { + return context.WithValue(ctx, commandArgContextKey, args) +} + +func CommandArgs(ctx context.Context) []string { + args, _ := ctx.Value(commandArgContextKey).([]string) + return args +} diff --git a/internal/common/ui/dispatcher/context.go b/internal/common/ui/dispatcher/context.go new file mode 100644 index 0000000..46e2fc2 --- /dev/null +++ b/internal/common/ui/dispatcher/context.go @@ -0,0 +1,32 @@ +package dispatcher + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" +) + +type DispatcherContext struct { + Publisher MessagePublisher +} + +func (dc DispatcherContext) Messagef(format string, args ...interface{}) { + dc.Publisher.Send(events.Message(fmt.Sprintf(format, args...))) +} + +func (dc DispatcherContext) Send(teaMessage tea.Msg) { + dc.Publisher.Send(teaMessage) +} + +func (dc DispatcherContext) Message(msg string) { + dc.Publisher.Send(events.Message(msg)) +} + +func (dc DispatcherContext) Input(prompt string, onDone uimodels.Operation) { + dc.Publisher.Send(events.PromptForInput{ + Prompt: prompt, + OnDone: onDone, + }) +} diff --git a/internal/common/ui/dispatcher/dispatcher.go b/internal/common/ui/dispatcher/dispatcher.go new file mode 100644 index 0000000..946c356 --- /dev/null +++ b/internal/common/ui/dispatcher/dispatcher.go @@ -0,0 +1,46 @@ +package dispatcher + +import ( + "context" + "sync" + + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/pkg/errors" +) + +type Dispatcher struct { + mutex *sync.Mutex + runningOp uimodels.Operation + publisher MessagePublisher +} + +func NewDispatcher(publisher MessagePublisher) *Dispatcher { + return &Dispatcher{ + mutex: new(sync.Mutex), + publisher: publisher, + } +} + +func (d *Dispatcher) Start(ctx context.Context, operation uimodels.Operation) { + d.mutex.Lock() + defer d.mutex.Unlock() + + if d.runningOp != nil { + d.publisher.Send(events.Error(errors.New("operation already running"))) + } + + d.runningOp = operation + go func() { + subCtx := uimodels.WithContext(ctx, DispatcherContext{d.publisher}) + + err := operation.Execute(subCtx) + if err != nil { + d.publisher.Send(events.Error(err)) + } + + d.mutex.Lock() + defer d.mutex.Unlock() + d.runningOp = nil + }() +} diff --git a/internal/common/ui/dispatcher/iface.go b/internal/common/ui/dispatcher/iface.go new file mode 100644 index 0000000..2af3399 --- /dev/null +++ b/internal/common/ui/dispatcher/iface.go @@ -0,0 +1,7 @@ +package dispatcher + +import tea "github.com/charmbracelet/bubbletea" + +type MessagePublisher interface { + Send(msg tea.Msg) +} diff --git a/internal/common/ui/events/errors.go b/internal/common/ui/events/errors.go new file mode 100644 index 0000000..0c031b6 --- /dev/null +++ b/internal/common/ui/events/errors.go @@ -0,0 +1,17 @@ +package events + +import ( + "github.com/lmika/awstools/internal/common/ui/uimodels" +) + +// Error indicates that an error occurred +type Error error + +// Message indicates that a message should be shown to the user +type Message string + +// PromptForInput indicates that the context is requesting a line of input +type PromptForInput struct { + Prompt string + OnDone uimodels.Operation +} diff --git a/internal/common/ui/uimodels/context.go b/internal/common/ui/uimodels/context.go new file mode 100644 index 0000000..9918aee --- /dev/null +++ b/internal/common/ui/uimodels/context.go @@ -0,0 +1,16 @@ +package uimodels + +import "context" + +type uiContextKeyType struct{} + +var uiContextKey = uiContextKeyType{} + +func Ctx(ctx context.Context) UIContext { + uiCtx, _ := ctx.Value(uiContextKey).(UIContext) + return uiCtx +} + +func WithContext(ctx context.Context, uiContext UIContext) context.Context { + return context.WithValue(ctx, uiContextKey, uiContext) +} diff --git a/internal/common/ui/uimodels/iface.go b/internal/common/ui/uimodels/iface.go new file mode 100644 index 0000000..002e83f --- /dev/null +++ b/internal/common/ui/uimodels/iface.go @@ -0,0 +1,10 @@ +package uimodels + +import tea "github.com/charmbracelet/bubbletea" + +type UIContext interface { + Send(teaMessage tea.Msg) + Message(msg string) + Messagef(format string, args ...interface{}) + Input(prompt string, onDone Operation) +} diff --git a/internal/common/ui/uimodels/operations.go b/internal/common/ui/uimodels/operations.go new file mode 100644 index 0000000..4eabbee --- /dev/null +++ b/internal/common/ui/uimodels/operations.go @@ -0,0 +1,13 @@ +package uimodels + +import "context" + +type Operation interface { + Execute(ctx context.Context) error +} + +type OperationFn func(ctx context.Context) error + +func (f OperationFn) Execute(ctx context.Context) error { + return f(ctx) +} diff --git a/internal/common/ui/uimodels/promptvalue.go b/internal/common/ui/uimodels/promptvalue.go new file mode 100644 index 0000000..7ac33de --- /dev/null +++ b/internal/common/ui/uimodels/promptvalue.go @@ -0,0 +1,16 @@ +package uimodels + +import "context" + +type promptValueKeyType struct{} + +var promptValueKey = promptValueKeyType{} + +func PromptValue(ctx context.Context) string { + value, _ := ctx.Value(promptValueKey).(string) + return value +} + +func WithPromptValue(ctx context.Context, value string) context.Context { + return context.WithValue(ctx, promptValueKey, value) +} diff --git a/internal/dynamo-browse/controllers/events.go b/internal/dynamo-browse/controllers/events.go new file mode 100644 index 0000000..1289b94 --- /dev/null +++ b/internal/dynamo-browse/controllers/events.go @@ -0,0 +1,11 @@ +package controllers + +import "github.com/lmika/awstools/internal/dynamo-browse/models" + +type NewResultSet struct { + ResultSet *models.ResultSet +} + +type SetReadWrite struct { + NewValue bool +} diff --git a/internal/dynamo-browse/controllers/state.go b/internal/dynamo-browse/controllers/state.go new file mode 100644 index 0000000..a711e6d --- /dev/null +++ b/internal/dynamo-browse/controllers/state.go @@ -0,0 +1,28 @@ +package controllers + +import ( + "context" + + "github.com/lmika/awstools/internal/dynamo-browse/models" +) + +type State struct { + ResultSet *models.ResultSet + SelectedItem models.Item + + // InReadWriteMode indicates whether modifications can be made to the table + InReadWriteMode bool +} + +type stateContextKeyType struct{} + +var stateContextKey = stateContextKeyType{} + +func CurrentState(ctx context.Context) State { + state, _ := ctx.Value(stateContextKey).(State) + return state +} + +func ContextWithState(ctx context.Context, state State) context.Context { + return context.WithValue(ctx, stateContextKey, state) +} diff --git a/internal/dynamo-browse/controllers/tableread.go b/internal/dynamo-browse/controllers/tableread.go new file mode 100644 index 0000000..94ade82 --- /dev/null +++ b/internal/dynamo-browse/controllers/tableread.go @@ -0,0 +1,66 @@ +package controllers + +import ( + "context" + + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/pkg/errors" +) + +type TableReadController struct { + tableService *tables.Service + tableName string +} + +func NewTableReadController(tableService *tables.Service, tableName string) *TableReadController { + return &TableReadController{ + tableService: tableService, + tableName: tableName, + } +} + +func (c *TableReadController) Scan() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + return c.doScan(ctx, false) + }) +} + +func (c *TableReadController) doScan(ctx context.Context, quiet bool) (err error) { + uiCtx := uimodels.Ctx(ctx) + + if !quiet { + uiCtx.Message("Scanning...") + } + + tableInfo, err := c.tableInfo(ctx) + if err != nil { + return err + } + + resultSet, err := c.tableService.Scan(ctx, tableInfo) + if err != nil { + return err + } + + if !quiet { + uiCtx.Messagef("Found %d items", len(resultSet.Items)) + } + uiCtx.Send(NewResultSet{resultSet}) + return nil +} + +// tableInfo returns the table info from the state if a result set exists. If not, it fetches the +// table information from the service. +func (c *TableReadController) tableInfo(ctx context.Context) (*models.TableInfo, error) { + if existingResultSet := CurrentState(ctx).ResultSet; existingResultSet != nil { + return existingResultSet.TableInfo, nil + } + + tableInfo, err := c.tableService.Describe(ctx, c.tableName) + if err != nil { + return nil, errors.Wrapf(err, "cannot describe %v", c.tableName) + } + return tableInfo, nil +} diff --git a/internal/dynamo-browse/controllers/tablewrite.go b/internal/dynamo-browse/controllers/tablewrite.go new file mode 100644 index 0000000..57b252c --- /dev/null +++ b/internal/dynamo-browse/controllers/tablewrite.go @@ -0,0 +1,134 @@ +package controllers + +import ( + "context" + + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/models/modexpr" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/pkg/errors" +) + +type TableWriteController struct { + tableService *tables.Service + tableReadControllers *TableReadController + tableName string +} + +func NewTableWriteController(tableService *tables.Service, tableReadControllers *TableReadController, tableName string) *TableWriteController { + return &TableWriteController{ + tableService: tableService, + tableReadControllers: tableReadControllers, + tableName: tableName, + } +} + +func (c *TableWriteController) ToggleReadWrite() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + state := CurrentState(ctx) + + if state.InReadWriteMode { + uiCtx.Send(SetReadWrite{NewValue: false}) + uiCtx.Message("read/write mode disabled") + } else { + uiCtx.Send(SetReadWrite{NewValue: true}) + uiCtx.Message("read/write mode enabled") + } + + return nil + }) +} + +func (c *TableWriteController) Duplicate() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + state := CurrentState(ctx) + + if state.SelectedItem == nil { + return errors.New("no selected item") + } else if !state.InReadWriteMode { + return errors.New("not in read/write mode") + } + + uiCtx.Input("Dup: ", uimodels.OperationFn(func(ctx context.Context) error { + modExpr, err := modexpr.Parse(uimodels.PromptValue(ctx)) + if err != nil { + return err + } + + newItem, err := modExpr.Patch(state.SelectedItem) + if err != nil { + return err + } + + // TODO: preview new item + + uiCtx := uimodels.Ctx(ctx) + uiCtx.Input("Put item? ", uimodels.OperationFn(func(ctx context.Context) error { + if uimodels.PromptValue(ctx) != "y" { + return errors.New("operation aborted") + } + + tableInfo, err := c.tableReadControllers.tableInfo(ctx) + if err != nil { + return err + } + + // Delete the item + if err := c.tableService.Put(ctx, tableInfo, newItem); err != nil { + return err + } + + // Rescan to get updated items + if err := c.tableReadControllers.doScan(ctx, true); err != nil { + return err + } + + return nil + })) + return nil + })) + return nil + }) +} + +func (c *TableWriteController) Delete() uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + state := CurrentState(ctx) + + if state.SelectedItem == nil { + return errors.New("no selected item") + } else if !state.InReadWriteMode { + return errors.New("not in read/write mode") + } + + uiCtx.Input("Delete item? ", uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + + if uimodels.PromptValue(ctx) != "y" { + return errors.New("operation aborted") + } + + tableInfo, err := c.tableReadControllers.tableInfo(ctx) + if err != nil { + return err + } + + // Delete the item + if err := c.tableService.Delete(ctx, tableInfo, state.SelectedItem); err != nil { + return err + } + + // Rescan to get updated items + if err := c.tableReadControllers.doScan(ctx, true); err != nil { + return err + } + + uiCtx.Message("Item deleted") + return nil + })) + return nil + }) +} diff --git a/internal/dynamo-browse/controllers/tablewrite_test.go b/internal/dynamo-browse/controllers/tablewrite_test.go new file mode 100644 index 0000000..635878b --- /dev/null +++ b/internal/dynamo-browse/controllers/tablewrite_test.go @@ -0,0 +1,187 @@ +package controllers_test + +import ( + "context" + "testing" + + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/controllers" + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/test/testdynamo" + "github.com/lmika/awstools/test/testuictx" + "github.com/stretchr/testify/assert" +) + +func TestTableWriteController_ToggleReadWrite(t *testing.T) { + twc, _, closeFn := setupController(t) + t.Cleanup(closeFn) + + t.Run("should enabling read write if disabled", func(t *testing.T) { + ctx, uiCtx := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + InReadWriteMode: false, + }) + + err := twc.ToggleReadWrite().Execute(ctx) + assert.NoError(t, err) + + assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: true}) + }) + + t.Run("should disable read write if enabled", func(t *testing.T) { + ctx, uiCtx := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + InReadWriteMode: true, + }) + + err := twc.ToggleReadWrite().Execute(ctx) + assert.NoError(t, err) + + assert.Contains(t, uiCtx.Messages, controllers.SetReadWrite{NewValue: false}) + }) +} + +func TestTableWriteController_Delete(t *testing.T) { + t.Run("should delete selected item if in read/write mode is inactive", func(t *testing.T) { + twc, ctrls, closeFn := setupController(t) + t.Cleanup(closeFn) + + ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) + assert.NoError(t, err) + + resultSet, err := ctrls.tableService.Scan(context.Background(), ti) + assert.NoError(t, err) + assert.Len(t, resultSet.Items, 3) + + ctx, uiCtx := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + ResultSet: resultSet, + SelectedItem: resultSet.Items[1], + InReadWriteMode: true, + }) + + op := twc.Delete() + + // Should prompt first + err = op.Execute(ctx) + assert.NoError(t, err) + + promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) + assert.True(t, ok) + + // After prompt, continue to delete + err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "y")) + assert.NoError(t, err) + + afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti) + assert.NoError(t, err) + assert.Len(t, afterResultSet.Items, 2) + assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) + assert.NotContains(t, afterResultSet.Items, resultSet.Items[1]) + assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) + }) + + t.Run("should not delete selected item if prompt is not y", func(t *testing.T) { + twc, ctrls, closeFn := setupController(t) + t.Cleanup(closeFn) + + ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) + assert.NoError(t, err) + + resultSet, err := ctrls.tableService.Scan(context.Background(), ti) + assert.NoError(t, err) + assert.Len(t, resultSet.Items, 3) + + ctx, uiCtx := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + ResultSet: resultSet, + SelectedItem: resultSet.Items[1], + InReadWriteMode: true, + }) + + op := twc.Delete() + + // Should prompt first + err = op.Execute(ctx) + assert.NoError(t, err) + + promptRequest, ok := uiCtx.Messages[0].(events.PromptForInput) + assert.True(t, ok) + + // After prompt, continue to delete + err = promptRequest.OnDone.Execute(uimodels.WithPromptValue(ctx, "n")) + assert.Error(t, err) + + afterResultSet, err := ctrls.tableService.Scan(context.Background(), ti) + assert.NoError(t, err) + assert.Len(t, afterResultSet.Items, 3) + assert.Contains(t, afterResultSet.Items, resultSet.Items[0]) + assert.Contains(t, afterResultSet.Items, resultSet.Items[1]) + assert.Contains(t, afterResultSet.Items, resultSet.Items[2]) + }) + + t.Run("should not delete if read/write mode is inactive", func(t *testing.T) { + tableWriteController, ctrls, closeFn := setupController(t) + t.Cleanup(closeFn) + + ti, err := ctrls.tableService.Describe(context.Background(), ctrls.tableName) + assert.NoError(t, err) + + resultSet, err := ctrls.tableService.Scan(context.Background(), ti) + assert.NoError(t, err) + assert.Len(t, resultSet.Items, 3) + + ctx, _ := testuictx.New(context.Background()) + ctx = controllers.ContextWithState(ctx, controllers.State{ + ResultSet: resultSet, + SelectedItem: resultSet.Items[1], + InReadWriteMode: false, + }) + + op := tableWriteController.Delete() + + err = op.Execute(ctx) + assert.Error(t, err) + }) +} + +type controller struct { + tableName string + tableService *tables.Service +} + +func setupController(t *testing.T) (*controllers.TableWriteController, controller, func()) { + tableName := "table-write-controller-table" + + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + provider := dynamo.NewProvider(client) + tableService := tables.NewService(provider) + tableReadController := controllers.NewTableReadController(tableService, tableName) + tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, tableName) + return tableWriteController, controller{ + tableName: tableName, + tableService: tableService, + }, cleanupFn +} + +var testData = testdynamo.TestData{ + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, +} diff --git a/internal/dynamo-browse/models/attrutils.go b/internal/dynamo-browse/models/attrutils.go new file mode 100644 index 0000000..cf1855b --- /dev/null +++ b/internal/dynamo-browse/models/attrutils.go @@ -0,0 +1,44 @@ +package models + +import ( + "math/big" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" +) + +func compareScalarAttributes(x, y types.AttributeValue) (int, bool) { + switch xVal := x.(type) { + case *types.AttributeValueMemberS: + if yVal, ok := y.(*types.AttributeValueMemberS); ok { + return comparisonValue(xVal.Value == yVal.Value, xVal.Value < yVal.Value), true + } + case *types.AttributeValueMemberN: + if yVal, ok := y.(*types.AttributeValueMemberN); ok { + xNumVal, _, err := big.ParseFloat(xVal.Value, 10, 63, big.ToNearestEven) + if err != nil { + return 0, false + } + + yNumVal, _, err := big.ParseFloat(yVal.Value, 10, 63, big.ToNearestEven) + if err != nil { + return 0, false + } + + return xNumVal.Cmp(yNumVal), true + } + case *types.AttributeValueMemberBOOL: + if yVal, ok := y.(*types.AttributeValueMemberBOOL); ok { + return comparisonValue(xVal.Value == yVal.Value, !xVal.Value), true + } + } + return 0, false +} + +func comparisonValue(isEqual bool, isLess bool) int { + if isEqual { + return 0 + } else if isLess { + return -1 + } + return 1 +} diff --git a/internal/dynamo-browse/models/models.go b/internal/dynamo-browse/models/models.go new file mode 100644 index 0000000..332c2f8 --- /dev/null +++ b/internal/dynamo-browse/models/models.go @@ -0,0 +1,32 @@ +package models + +import "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + +type ResultSet struct { + TableInfo *TableInfo + Columns []string + Items []Item +} + +type Item map[string]types.AttributeValue + +// Clone creates a clone of the current item +func (i Item) Clone() Item { + newItem := Item{} + + // TODO: should be a deep clone? + for k, v := range i { + newItem[k] = v + } + + return newItem +} + +func (i Item) KeyValue(info *TableInfo) map[string]types.AttributeValue { + itemKey := make(map[string]types.AttributeValue) + itemKey[info.Keys.PartitionKey] = i[info.Keys.PartitionKey] + if info.Keys.SortKey != "" { + itemKey[info.Keys.SortKey] = i[info.Keys.SortKey] + } + return itemKey +} diff --git a/internal/dynamo-browse/models/modexpr/ast.go b/internal/dynamo-browse/models/modexpr/ast.go new file mode 100644 index 0000000..e694acb --- /dev/null +++ b/internal/dynamo-browse/models/modexpr/ast.go @@ -0,0 +1,35 @@ +package modexpr + +import ( + "github.com/alecthomas/participle/v2" + "github.com/pkg/errors" +) + +type astExpr struct { + Attributes []*astAttribute `parser:"@@ (',' @@)*"` +} + +type astAttribute struct { + Names *astKeyList `parser:"@@ '='"` + Value *astLiteralValue `parser:"@@"` +} + +type astKeyList struct { + Names []string `parser:"@Ident ('/' @Ident)*"` +} + +type astLiteralValue struct { + String string `parser:"@String"` +} + +var parser = participle.MustBuild(&astExpr{}) + +func Parse(expr string) (*ModExpr, error) { + var ast astExpr + + if err := parser.ParseString("expr", expr, &ast); err != nil { + return nil, errors.Wrapf(err, "cannot parse expression: '%v'", expr) + } + + return &ModExpr{ast: &ast}, nil +} diff --git a/internal/dynamo-browse/models/modexpr/astmods.go b/internal/dynamo-browse/models/modexpr/astmods.go new file mode 100644 index 0000000..0247bf7 --- /dev/null +++ b/internal/dynamo-browse/models/modexpr/astmods.go @@ -0,0 +1,31 @@ +package modexpr + +import "github.com/lmika/awstools/internal/dynamo-browse/models" + +func (a *astExpr) calcPatchMods(item models.Item) ([]patchMod, error) { + patchMods := make([]patchMod, 0) + + for _, attr := range a.Attributes { + attrPatchMods, err := attr.calcPatchMods(item) + if err != nil { + return nil, err + } + patchMods = append(patchMods, attrPatchMods...) + } + + return patchMods, nil +} + +func (a *astAttribute) calcPatchMods(item models.Item) ([]patchMod, error) { + value, err := a.Value.dynamoValue() + if err != nil { + return nil, err + } + + patchMods := make([]patchMod, 0) + for _, key := range a.Names.Names { + patchMods = append(patchMods, setAttributeMod{key: key, to: value}) + } + + return patchMods, nil +} diff --git a/internal/dynamo-browse/models/modexpr/expr.go b/internal/dynamo-browse/models/modexpr/expr.go new file mode 100644 index 0000000..7f26e59 --- /dev/null +++ b/internal/dynamo-browse/models/modexpr/expr.go @@ -0,0 +1,21 @@ +package modexpr + +import "github.com/lmika/awstools/internal/dynamo-browse/models" + +type ModExpr struct { + ast *astExpr +} + +func (me *ModExpr) Patch(item models.Item) (models.Item, error) { + mods, err := me.ast.calcPatchMods(item) + if err != nil { + return nil, err + } + + newItem := item.Clone() + for _, mod := range mods { + mod.Apply(newItem) + } + + return newItem, nil +} diff --git a/internal/dynamo-browse/models/modexpr/expr_test.go b/internal/dynamo-browse/models/modexpr/expr_test.go new file mode 100644 index 0000000..3b369ea --- /dev/null +++ b/internal/dynamo-browse/models/modexpr/expr_test.go @@ -0,0 +1,56 @@ +package modexpr_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/lmika/awstools/internal/dynamo-browse/models/modexpr" + "github.com/stretchr/testify/assert" +) + +func TestModExpr_Patch(t *testing.T) { + t.Run("patch with new attributes", func(t *testing.T) { + modExpr, err := modexpr.Parse(`alpha="new value", beta="another new value"`) + assert.NoError(t, err) + + oldItem := models.Item{} + newItem, err := modExpr.Patch(oldItem) + assert.NoError(t, err) + + assert.Equal(t, "new value", newItem["alpha"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "another new value", newItem["beta"].(*types.AttributeValueMemberS).Value) + }) + + t.Run("patch with existing attributes", func(t *testing.T) { + modExpr, err := modexpr.Parse(`alpha="new value", beta="another new value"`) + assert.NoError(t, err) + + oldItem := models.Item{ + "old": &types.AttributeValueMemberS{Value: "before"}, + "beta": &types.AttributeValueMemberS{Value: "before beta"}, + } + newItem, err := modExpr.Patch(oldItem) + assert.NoError(t, err) + + assert.Equal(t, "before", newItem["old"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "new value", newItem["alpha"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "another new value", newItem["beta"].(*types.AttributeValueMemberS).Value) + }) + + t.Run("patch with key tuple", func(t *testing.T) { + modExpr, err := modexpr.Parse(`alpha/beta="new value"`) + assert.NoError(t, err) + + oldItem := models.Item{ + "old": &types.AttributeValueMemberS{Value: "before"}, + "beta": &types.AttributeValueMemberS{Value: "before beta"}, + } + newItem, err := modExpr.Patch(oldItem) + assert.NoError(t, err) + + assert.Equal(t, "before", newItem["old"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "new value", newItem["alpha"].(*types.AttributeValueMemberS).Value) + assert.Equal(t, "new value", newItem["beta"].(*types.AttributeValueMemberS).Value) + }) +} diff --git a/internal/dynamo-browse/models/modexpr/mods.go b/internal/dynamo-browse/models/modexpr/mods.go new file mode 100644 index 0000000..6839d73 --- /dev/null +++ b/internal/dynamo-browse/models/modexpr/mods.go @@ -0,0 +1,19 @@ +package modexpr + +import ( + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models" +) + +type patchMod interface { + Apply(item models.Item) +} + +type setAttributeMod struct { + key string + to types.AttributeValue +} + +func (sa setAttributeMod) Apply(item models.Item) { + item[sa.key] = sa.to +} diff --git a/internal/dynamo-browse/models/modexpr/values.go b/internal/dynamo-browse/models/modexpr/values.go new file mode 100644 index 0000000..15b404b --- /dev/null +++ b/internal/dynamo-browse/models/modexpr/values.go @@ -0,0 +1,16 @@ +package modexpr + +import ( + "strconv" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/pkg/errors" +) + +func (a *astLiteralValue) dynamoValue() (types.AttributeValue, error) { + s, err := strconv.Unquote(a.String) + if err != nil { + return nil, errors.Wrap(err, "cannot unquote string") + } + return &types.AttributeValueMemberS{Value: s}, nil +} diff --git a/internal/dynamo-browse/models/sorted.go b/internal/dynamo-browse/models/sorted.go new file mode 100644 index 0000000..fec19a9 --- /dev/null +++ b/internal/dynamo-browse/models/sorted.go @@ -0,0 +1,57 @@ +package models + +import "sort" + +// sortedItems is a collection of items that is sorted. +// Items are sorted based on the PK, and SK in ascending order +type sortedItems struct { + tableInfo *TableInfo + items []Item +} + +// Sort sorts the items in place +func Sort(items []Item, tableInfo *TableInfo) { + si := sortedItems{items: items, tableInfo: tableInfo} + sort.Sort(&si) +} + +func (si *sortedItems) Len() int { + return len(si.items) +} + +func (si *sortedItems) Less(i, j int) bool { + // Compare primary keys + pv1, pv2 := si.items[i][si.tableInfo.Keys.PartitionKey], si.items[j][si.tableInfo.Keys.PartitionKey] + pc, ok := compareScalarAttributes(pv1, pv2) + if !ok { + return i < j + } + + if pc < 0 { + return true + } else if pc > 0 { + return false + } + + // Partition keys are equal, compare sort key + if sortKey := si.tableInfo.Keys.SortKey; sortKey != "" { + sv1, sv2 := si.items[i][sortKey], si.items[j][sortKey] + sc, ok := compareScalarAttributes(sv1, sv2) + if !ok { + return i < j + } + + if sc < 0 { + return true + } else if sc > 0 { + return false + } + } + + // This should never happen, but just in case + return i < j +} + +func (si *sortedItems) Swap(i, j int) { + si.items[j], si.items[i] = si.items[i], si.items[j] +} diff --git a/internal/dynamo-browse/models/sorted_test.go b/internal/dynamo-browse/models/sorted_test.go new file mode 100644 index 0000000..1925711 --- /dev/null +++ b/internal/dynamo-browse/models/sorted_test.go @@ -0,0 +1,110 @@ +package models_test + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/stretchr/testify/assert" +) + +func TestSort(t *testing.T) { + t.Run("pk and sk are both strings", func(t *testing.T) { + tableInfo := &models.TableInfo{Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}} + + items := make([]models.Item, len(testStringData)) + copy(items, testStringData) + + models.Sort(items, tableInfo) + + assert.Equal(t, items[0], testStringData[1]) + assert.Equal(t, items[1], testStringData[2]) + assert.Equal(t, items[2], testStringData[0]) + }) + + t.Run("pk and sk are both numbers", func(t *testing.T) { + tableInfo := &models.TableInfo{Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}} + + items := make([]models.Item, len(testNumberData)) + copy(items, testNumberData) + + models.Sort(items, tableInfo) + + assert.Equal(t, items[0], testNumberData[2]) + assert.Equal(t, items[1], testNumberData[1]) + assert.Equal(t, items[2], testNumberData[0]) + }) + + t.Run("pk and sk are both bools", func(t *testing.T) { + tableInfo := &models.TableInfo{Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}} + + items := make([]models.Item, len(testBoolData)) + copy(items, testBoolData) + + models.Sort(items, tableInfo) + + assert.Equal(t, items[0], testBoolData[2]) + assert.Equal(t, items[1], testBoolData[1]) + assert.Equal(t, items[2], testBoolData[0]) + }) +} + +var testStringData = []models.Item{ + { + "pk": &types.AttributeValueMemberS{Value: "bbb"}, + "sk": &types.AttributeValueMemberS{Value: "131"}, + "beta": &types.AttributeValueMemberN{Value: "2468"}, + "gamma": &types.AttributeValueMemberS{Value: "foobar"}, + }, + { + "pk": &types.AttributeValueMemberS{Value: "abc"}, + "sk": &types.AttributeValueMemberS{Value: "111"}, + "alpha": &types.AttributeValueMemberS{Value: "This is some value"}, + }, + { + "pk": &types.AttributeValueMemberS{Value: "abc"}, + "sk": &types.AttributeValueMemberS{Value: "222"}, + "alpha": &types.AttributeValueMemberS{Value: "This is another some value"}, + "beta": &types.AttributeValueMemberN{Value: "2468"}, + }, +} + +var testNumberData = []models.Item{ + { + "pk": &types.AttributeValueMemberN{Value: "1141"}, + "sk": &types.AttributeValueMemberN{Value: "1111"}, + "beta": &types.AttributeValueMemberN{Value: "2468"}, + "gamma": &types.AttributeValueMemberS{Value: "foobar"}, + }, + { + "pk": &types.AttributeValueMemberN{Value: "1141"}, + "sk": &types.AttributeValueMemberN{Value: "111.5"}, + "alpha": &types.AttributeValueMemberS{Value: "This is some value"}, + }, + { + "pk": &types.AttributeValueMemberN{Value: "5"}, + "sk": &types.AttributeValueMemberN{Value: "222"}, + "alpha": &types.AttributeValueMemberS{Value: "This is another some value"}, + "beta": &types.AttributeValueMemberN{Value: "2468"}, + }, +} + +var testBoolData = []models.Item{ + { + "pk": &types.AttributeValueMemberBOOL{Value: true}, + "sk": &types.AttributeValueMemberBOOL{Value: true}, + "beta": &types.AttributeValueMemberN{Value: "2468"}, + "gamma": &types.AttributeValueMemberS{Value: "foobar"}, + }, + { + "pk": &types.AttributeValueMemberBOOL{Value: true}, + "sk": &types.AttributeValueMemberBOOL{Value: false}, + "alpha": &types.AttributeValueMemberS{Value: "This is some value"}, + }, + { + "pk": &types.AttributeValueMemberBOOL{Value: false}, + "sk": &types.AttributeValueMemberBOOL{Value: false}, + "alpha": &types.AttributeValueMemberS{Value: "This is another some value"}, + "beta": &types.AttributeValueMemberN{Value: "2468"}, + }, +} diff --git a/internal/dynamo-browse/models/tableinfo.go b/internal/dynamo-browse/models/tableinfo.go new file mode 100644 index 0000000..0001684 --- /dev/null +++ b/internal/dynamo-browse/models/tableinfo.go @@ -0,0 +1,12 @@ +package models + +type TableInfo struct { + Name string + Keys KeyAttribute + DefinedAttributes []string +} + +type KeyAttribute struct { + PartitionKey string + SortKey string +} diff --git a/internal/dynamo-browse/providers/dynamo/provider.go b/internal/dynamo-browse/providers/dynamo/provider.go new file mode 100644 index 0000000..7636845 --- /dev/null +++ b/internal/dynamo-browse/providers/dynamo/provider.go @@ -0,0 +1,80 @@ +package dynamo + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/pkg/errors" +) + +type Provider struct { + client *dynamodb.Client +} + +func (p *Provider) DescribeTable(ctx context.Context, tableName string) (*models.TableInfo, error) { + out, err := p.client.DescribeTable(ctx, &dynamodb.DescribeTableInput{ + TableName: aws.String(tableName), + }) + if err != nil { + return nil, errors.Wrapf(err, "cannot describe table %v", tableName) + } + + var tableInfo models.TableInfo + tableInfo.Name = aws.ToString(out.Table.TableName) + + for _, keySchema := range out.Table.KeySchema { + if keySchema.KeyType == types.KeyTypeHash { + tableInfo.Keys.PartitionKey = aws.ToString(keySchema.AttributeName) + } else if keySchema.KeyType == types.KeyTypeRange { + tableInfo.Keys.SortKey = aws.ToString(keySchema.AttributeName) + } + } + + for _, definedAttribute := range out.Table.AttributeDefinitions { + tableInfo.DefinedAttributes = append(tableInfo.DefinedAttributes, aws.ToString(definedAttribute.AttributeName)) + } + + return &tableInfo, nil +} + +func (p *Provider) PutItem(ctx context.Context, name string, item models.Item) error { + _, err := p.client.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(name), + Item: item, + }) + if err != nil { + return errors.Wrapf(err, "cannot execute put on table %v", name) + } + return nil +} + +func NewProvider(client *dynamodb.Client) *Provider { + return &Provider{client: client} +} + +func (p *Provider) ScanItems(ctx context.Context, tableName string) ([]models.Item, error) { + res, err := p.client.Scan(ctx, &dynamodb.ScanInput{ + TableName: aws.String(tableName), + }) + if err != nil { + return nil, errors.Wrapf(err, "cannot execute scan on table %v", tableName) + } + + items := make([]models.Item, len(res.Items)) + for i, itm := range res.Items { + items[i] = itm + } + + return items, nil +} + +func (p *Provider) DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error { + _, err := p.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{ + TableName: aws.String(tableName), + Key: key, + }) + return errors.Wrap(err, "could not delete item") +} diff --git a/internal/dynamo-browse/providers/dynamo/provider_test.go b/internal/dynamo-browse/providers/dynamo/provider_test.go new file mode 100644 index 0000000..a408bc2 --- /dev/null +++ b/internal/dynamo-browse/providers/dynamo/provider_test.go @@ -0,0 +1,118 @@ +package dynamo_test + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/test/testdynamo" + "github.com/stretchr/testify/assert" +) + +func TestProvider_ScanItems(t *testing.T) { + tableName := "provider-scanimages-test-table" + + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + defer cleanupFn() + provider := dynamo.NewProvider(client) + + t.Run("should return scanned items from the table", func(t *testing.T) { + ctx := context.Background() + + items, err := provider.ScanItems(ctx, tableName) + assert.NoError(t, err) + assert.Len(t, items, 3) + + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) + }) + + t.Run("should return error if table name does not exist", func(t *testing.T) { + ctx := context.Background() + + items, err := provider.ScanItems(ctx, "does-not-exist") + assert.Error(t, err) + assert.Nil(t, items) + }) +} + +func TestProvider_DeleteItem(t *testing.T) { + tableName := "provider-deleteitem-test-table" + + t.Run("should delete item if exists in table", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + defer cleanupFn() + provider := dynamo.NewProvider(client) + + ctx := context.Background() + + err := provider.DeleteItem(ctx, tableName, map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: "abc"}, + "sk": &types.AttributeValueMemberS{Value: "222"}, + }) + + items, err := provider.ScanItems(ctx, tableName) + assert.NoError(t, err) + assert.Len(t, items, 2) + + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) + assert.NotContains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) + + }) + + t.Run("should do nothing if key does not exist", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + defer cleanupFn() + provider := dynamo.NewProvider(client) + + ctx := context.Background() + + err := provider.DeleteItem(ctx, tableName, map[string]types.AttributeValue{ + "pk": &types.AttributeValueMemberS{Value: "zyx"}, + "sk": &types.AttributeValueMemberS{Value: "999"}, + }) + + items, err := provider.ScanItems(ctx, tableName) + assert.NoError(t, err) + assert.Len(t, items, 3) + + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[0])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[1])) + assert.Contains(t, items, testdynamo.TestRecordAsItem(t, testData[2])) + }) + + t.Run("should return error if table name does not exist", func(t *testing.T) { + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + defer cleanupFn() + provider := dynamo.NewProvider(client) + + ctx := context.Background() + + items, err := provider.ScanItems(ctx, "does-not-exist") + assert.Error(t, err) + assert.Nil(t, items) + }) +} + +var testData = testdynamo.TestData{ + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, +} diff --git a/internal/dynamo-browse/services/tables/iface.go b/internal/dynamo-browse/services/tables/iface.go new file mode 100644 index 0000000..aedae2e --- /dev/null +++ b/internal/dynamo-browse/services/tables/iface.go @@ -0,0 +1,15 @@ +package tables + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/lmika/awstools/internal/dynamo-browse/models" +) + +type TableProvider interface { + DescribeTable(ctx context.Context, tableName string) (*models.TableInfo, error) + ScanItems(ctx context.Context, tableName string) ([]models.Item, error) + DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error + PutItem(ctx context.Context, name string, item models.Item) error +} diff --git a/internal/dynamo-browse/services/tables/service.go b/internal/dynamo-browse/services/tables/service.go new file mode 100644 index 0000000..22aab20 --- /dev/null +++ b/internal/dynamo-browse/services/tables/service.go @@ -0,0 +1,79 @@ +package tables + +import ( + "context" + "sort" + + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/pkg/errors" +) + +type Service struct { + provider TableProvider +} + +func NewService(provider TableProvider) *Service { + return &Service{ + provider: provider, + } +} + +func (s *Service) Describe(ctx context.Context, table string) (*models.TableInfo, error) { + return s.provider.DescribeTable(ctx, table) +} + +func (s *Service) Scan(ctx context.Context, tableInfo *models.TableInfo) (*models.ResultSet, error) { + results, err := s.provider.ScanItems(ctx, tableInfo.Name) + if err != nil { + return nil, errors.Wrapf(err, "unable to scan table %v", tableInfo.Name) + } + + // Get the columns + seenColumns := make(map[string]int) + seenColumns[tableInfo.Keys.PartitionKey] = 0 + if tableInfo.Keys.SortKey != "" { + seenColumns[tableInfo.Keys.SortKey] = 1 + } + + for _, definedAttribute := range tableInfo.DefinedAttributes { + if _, seen := seenColumns[definedAttribute]; !seen { + seenColumns[definedAttribute] = len(seenColumns) + } + } + + otherColsRank := len(seenColumns) + for _, result := range results { + for k := range result { + if _, isSeen := seenColumns[k]; !isSeen { + seenColumns[k] = otherColsRank + } + } + } + + columns := make([]string, 0, len(seenColumns)) + for k := range seenColumns { + columns = append(columns, k) + } + sort.Slice(columns, func(i, j int) bool { + if seenColumns[columns[i]] == seenColumns[columns[j]] { + return columns[i] < columns[j] + } + return seenColumns[columns[i]] < seenColumns[columns[j]] + }) + + models.Sort(results, tableInfo) + + return &models.ResultSet{ + TableInfo: tableInfo, + Columns: columns, + Items: results, + }, nil +} + +func (s *Service) Put(ctx context.Context, tableInfo *models.TableInfo, item models.Item) error { + return s.provider.PutItem(ctx, tableInfo.Name, item) +} + +func (s *Service) Delete(ctx context.Context, tableInfo *models.TableInfo, item models.Item) error { + return s.provider.DeleteItem(ctx, tableInfo.Name, item.KeyValue(tableInfo)) +} diff --git a/internal/dynamo-browse/services/tables/service_test.go b/internal/dynamo-browse/services/tables/service_test.go new file mode 100644 index 0000000..c5c8b37 --- /dev/null +++ b/internal/dynamo-browse/services/tables/service_test.go @@ -0,0 +1,79 @@ +package tables_test + +import ( + "context" + "testing" + + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/awstools/test/testdynamo" + "github.com/stretchr/testify/assert" +) + +func TestService_Describe(t *testing.T) { + tableName := "service-describe-table" + + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + defer cleanupFn() + provider := dynamo.NewProvider(client) + + t.Run("return details of the table", func(t *testing.T) { + ctx := context.Background() + + service := tables.NewService(provider) + ti, err := service.Describe(ctx, tableName) + assert.NoError(t, err) + + // Hash first, then range, then columns in alphabetic order + assert.Equal(t, ti.Name, tableName) + assert.Equal(t, "pk", ti.Keys.PartitionKey, "pk") + assert.Equal(t, "sk", ti.Keys.SortKey, "sk") + assert.Equal(t, []string{"pk", "sk"}, ti.DefinedAttributes) + }) +} + +func TestService_Scan(t *testing.T) { + tableName := "service-scan-test-table" + + client, cleanupFn := testdynamo.SetupTestTable(t, tableName, testData) + defer cleanupFn() + provider := dynamo.NewProvider(client) + + t.Run("return all columns and fields in sorted order", func(t *testing.T) { + ctx := context.Background() + + service := tables.NewService(provider) + ti, err := service.Describe(ctx, tableName) + assert.NoError(t, err) + + rs, err := service.Scan(ctx, ti) + assert.NoError(t, err) + + // Hash first, then range, then columns in alphabetic order + assert.Equal(t, rs.TableInfo, ti) + assert.Equal(t, rs.Columns, []string{"pk", "sk", "alpha", "beta", "gamma"}) + assert.Equal(t, rs.Items[0], testdynamo.TestRecordAsItem(t, testData[1])) + assert.Equal(t, rs.Items[1], testdynamo.TestRecordAsItem(t, testData[0])) + assert.Equal(t, rs.Items[2], testdynamo.TestRecordAsItem(t, testData[2])) + }) +} + +var testData = testdynamo.TestData{ + { + "pk": "abc", + "sk": "222", + "alpha": "This is another some value", + "beta": 1231, + }, + { + "pk": "abc", + "sk": "111", + "alpha": "This is some value", + }, + { + "pk": "bbb", + "sk": "131", + "beta": 2468, + "gamma": "foobar", + }, +} diff --git a/internal/dynamo-browse/ui/iface.go b/internal/dynamo-browse/ui/iface.go new file mode 100644 index 0000000..ade311a --- /dev/null +++ b/internal/dynamo-browse/ui/iface.go @@ -0,0 +1,7 @@ +package ui + +import tea "github.com/charmbracelet/bubbletea" + +type MessagePublisher interface { + Send(msg tea.Msg) +} diff --git a/internal/dynamo-browse/ui/model.go b/internal/dynamo-browse/ui/model.go new file mode 100644 index 0000000..00b994c --- /dev/null +++ b/internal/dynamo-browse/ui/model.go @@ -0,0 +1,289 @@ +package ui + +import ( + "context" + "fmt" + "strings" + "text/tabwriter" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + table "github.com/calyptia/go-bubble-table" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/common/ui/commandctrl" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/dynamo-browse/controllers" +) + +var ( + activeHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#4479ff")) + + inactiveHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")) +) + +type uiModel struct { + table table.Model + viewport viewport.Model + + tableWidth, tableHeight int + + ready bool + //resultSet *models.ResultSet + state controllers.State + message string + + pendingInput *events.PromptForInput + textInput textinput.Model + + dispatcher *dispatcher.Dispatcher + commandController *commandctrl.CommandController + tableReadController *controllers.TableReadController + tableWriteController *controllers.TableWriteController +} + +func NewModel(dispatcher *dispatcher.Dispatcher, commandController *commandctrl.CommandController, tableReadController *controllers.TableReadController, tableWriteController *controllers.TableWriteController) tea.Model { + tbl := table.New([]string{"pk", "sk"}, 100, 20) + rows := make([]table.Row, 0) + tbl.SetRows(rows) + + textInput := textinput.New() + + model := uiModel{ + table: tbl, + message: "Press s to scan", + textInput: textInput, + + dispatcher: dispatcher, + commandController: commandController, + tableReadController: tableReadController, + tableWriteController: tableWriteController, + } + + return model +} + +func (m uiModel) Init() tea.Cmd { + m.invokeOperation(context.Background(), m.tableReadController.Scan()) + return nil +} + +func (m *uiModel) updateTable() { + if !m.ready { + return + } + + resultSet := m.state.ResultSet + newTbl := table.New(resultSet.Columns, m.tableWidth, m.tableHeight) + newRows := make([]table.Row, len(resultSet.Items)) + for i, r := range resultSet.Items { + newRows[i] = itemTableRow{resultSet, r} + } + newTbl.SetRows(newRows) + + m.table = newTbl +} + +func (m *uiModel) selectedItem() (itemTableRow, bool) { + resultSet := m.state.ResultSet + if m.ready && resultSet != nil && len(resultSet.Items) > 0 { + selectedItem, ok := m.table.SelectedRow().(itemTableRow) + if ok { + return selectedItem, true + } + } + + return itemTableRow{}, false +} + +func (m *uiModel) updateViewportToSelectedMessage() { + selectedItem, ok := m.selectedItem() + if !ok { + m.viewport.SetContent("(no row selected)") + return + } + + viewportContent := &strings.Builder{} + tabWriter := tabwriter.NewWriter(viewportContent, 0, 1, 1, ' ', 0) + for _, colName := range selectedItem.resultSet.Columns { + switch colVal := selectedItem.item[colName].(type) { + case nil: + break + case *types.AttributeValueMemberS: + fmt.Fprintf(tabWriter, "%v\tS\t%s\n", colName, colVal.Value) + case *types.AttributeValueMemberN: + fmt.Fprintf(tabWriter, "%v\tN\t%s\n", colName, colVal.Value) + default: + fmt.Fprintf(tabWriter, "%v\t?\t%s\n", colName, "(other)") + } + } + + tabWriter.Flush() + m.viewport.SetContent(viewportContent.String()) +} + +func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var textInputCommands tea.Cmd + + switch msg := msg.(type) { + + // Local events + case controllers.NewResultSet: + m.state.ResultSet = msg.ResultSet + m.updateTable() + m.updateViewportToSelectedMessage() + case controllers.SetReadWrite: + m.state.InReadWriteMode = msg.NewValue + + // Shared events + case events.Error: + m.message = "Error: " + msg.Error() + case events.Message: + m.message = string(msg) + case events.PromptForInput: + m.textInput.Prompt = msg.Prompt + m.textInput.Focus() + m.textInput.SetValue("") + m.pendingInput = &msg + + // Tea events + case tea.WindowSizeMsg: + fixedViewsHeight := lipgloss.Height(m.headerView()) + lipgloss.Height(m.splitterView()) + lipgloss.Height(m.footerView()) + viewportHeight := msg.Height / 2 // TODO: make this dynamic + if viewportHeight > 15 { + viewportHeight = 15 + } + tableHeight := msg.Height - fixedViewsHeight - viewportHeight + + if !m.ready { + m.viewport = viewport.New(msg.Width, viewportHeight) + m.viewport.SetContent("(no message selected)") + m.ready = true + } else { + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - tableHeight - fixedViewsHeight + } + + m.tableWidth, m.tableHeight = msg.Width, tableHeight + m.table.SetSize(m.tableWidth, m.tableHeight) + + case tea.KeyMsg: + + // If text input in focus, allow that to accept input messages + if m.pendingInput != nil { + switch msg.String() { + case "ctrl+c", "esc": + m.pendingInput = nil + case "enter": + m.invokeOperation(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone) + m.pendingInput = nil + default: + m.textInput, textInputCommands = m.textInput.Update(msg) + } + break + } + + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "up", "i": + m.table.GoUp() + m.updateViewportToSelectedMessage() + case "down", "k": + m.table.GoDown() + m.updateViewportToSelectedMessage() + + // TODO: these should be moved somewhere else + case ":": + m.invokeOperation(context.Background(), m.commandController.Prompt()) + case "s": + m.invokeOperation(context.Background(), m.tableReadController.Scan()) + case "D": + m.invokeOperation(context.Background(), m.tableWriteController.Delete()) + } + default: + m.textInput, textInputCommands = m.textInput.Update(msg) + } + + updatedTable, tableMsgs := m.table.Update(msg) + updatedViewport, viewportMsgs := m.viewport.Update(msg) + + m.table = updatedTable + m.viewport = updatedViewport + + return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs) +} + +func (m uiModel) invokeOperation(ctx context.Context, op uimodels.Operation) { + state := m.state + if selectedItem, ok := m.selectedItem(); ok { + state.SelectedItem = selectedItem.item + } + + ctx = controllers.ContextWithState(ctx, state) + m.dispatcher.Start(ctx, op) +} + +func (m uiModel) View() string { + if !m.ready { + return "Initializing" + } + + if m.pendingInput != nil { + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.textInput.View(), + ) + } + + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.footerView(), + ) +} + +func (m uiModel) headerView() string { + var titleText string + if m.state.ResultSet != nil { + titleText = "Table: " + m.state.ResultSet.TableInfo.Name + } else { + titleText = "No table" + } + + title := activeHeaderStyle.Render(titleText) + line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func (m uiModel) splitterView() string { + title := inactiveHeaderStyle.Render("Item") + line := inactiveHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func (m uiModel) footerView() string { + title := m.message + line := strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/dynamo-browse/ui/tblmodel.go b/internal/dynamo-browse/ui/tblmodel.go new file mode 100644 index 0000000..6cb8d41 --- /dev/null +++ b/internal/dynamo-browse/ui/tblmodel.go @@ -0,0 +1,41 @@ +package ui + +import ( + "fmt" + "io" + "strings" + + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + table "github.com/calyptia/go-bubble-table" + "github.com/lmika/awstools/internal/dynamo-browse/models" +) + +type itemTableRow struct { + resultSet *models.ResultSet + item models.Item +} + +func (mtr itemTableRow) Render(w io.Writer, model table.Model, index int) { + sb := strings.Builder{} + for i, colName := range mtr.resultSet.Columns { + if i > 0 { + sb.WriteString("\t") + } + + switch colVal := mtr.item[colName].(type) { + case nil: + sb.WriteString("(nil)") + case *types.AttributeValueMemberS: + sb.WriteString(colVal.Value) + case *types.AttributeValueMemberN: + sb.WriteString(colVal.Value) + default: + sb.WriteString("(other)") + } + } + if index == model.Cursor() { + fmt.Fprintln(w, model.Styles.SelectedRow.Render(sb.String())) + } else { + fmt.Fprintln(w, sb.String()) + } +} diff --git a/internal/sqs-browse/controllers/forward.go b/internal/sqs-browse/controllers/forward.go new file mode 100644 index 0000000..ffd9b62 --- /dev/null +++ b/internal/sqs-browse/controllers/forward.go @@ -0,0 +1,40 @@ +package controllers + +import ( + "context" + + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/sqs-browse/models" + "github.com/lmika/awstools/internal/sqs-browse/services/messages" + "github.com/pkg/errors" +) + +type MessageSendingController struct { + messageService *messages.Service + targetQueue string +} + +func NewMessageSendingController(messageService *messages.Service, targetQueue string) *MessageSendingController { + return &MessageSendingController{ + messageService: messageService, + targetQueue: targetQueue, + } +} + +func (msh *MessageSendingController) ForwardMessage(message models.Message) uimodels.Operation { + return uimodels.OperationFn(func(ctx context.Context) error { + uiCtx := uimodels.Ctx(ctx) + + if msh.targetQueue == "" { + return errors.New("target queue not set") + } + + messageId, err := msh.messageService.SendTo(ctx, message, msh.targetQueue) + if err != nil { + return errors.Wrapf(err, "cannot send message to %v", msh.targetQueue) + } + + uiCtx.Message("Message sent to " + msh.targetQueue + ", id = " + messageId) + return nil + }) +} diff --git a/internal/sqs-browse/models/message.go b/internal/sqs-browse/models/message.go new file mode 100644 index 0000000..7a22466 --- /dev/null +++ b/internal/sqs-browse/models/message.go @@ -0,0 +1,11 @@ +package models + +import "time" + +type Message struct { + ID uint64 `storm:"id,increment"` + ExtID string `storm:"unique"` + Queue string `storm:"index"` + Received time.Time + Data string +} diff --git a/internal/sqs-browse/providers/sqs/provider.go b/internal/sqs-browse/providers/sqs/provider.go new file mode 100644 index 0000000..4307b45 --- /dev/null +++ b/internal/sqs-browse/providers/sqs/provider.go @@ -0,0 +1,78 @@ +package sqs + +import ( + "context" + "log" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sqs" + "github.com/aws/aws-sdk-go-v2/service/sqs/types" + "github.com/lmika/awstools/internal/sqs-browse/models" + "github.com/pkg/errors" +) + +type Provider struct { + client *sqs.Client +} + +func NewProvider(client *sqs.Client) *Provider { + return &Provider{client: client} +} + +func (p *Provider) SendMessage(ctx context.Context, msg models.Message, queue string) (string, error) { + // TEMP :: queue URL + + out, err := p.client.SendMessage(ctx, &sqs.SendMessageInput{ + QueueUrl: aws.String(queue), + MessageBody: aws.String(msg.Data), + }) + if err != nil { + return "", errors.Wrapf(err, "unable to send message to %v", queue) + } + + return aws.ToString(out.MessageId), nil +} + +func (p *Provider) PollForNewMessages(ctx context.Context, queue string) ([]*models.Message, error) { + out, err := p.client.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{ + QueueUrl: aws.String(queue), + MaxNumberOfMessages: 10, + WaitTimeSeconds: 20, + }) + if err != nil { + return nil, errors.Wrapf(err, "unable to receive messages from queue %v", queue) + } + + if len(out.Messages) == 0 { + return nil, nil + } + + messagesToReturn := make([]*models.Message, 0, len(out.Messages)) + messagesToDelete := make([]types.DeleteMessageBatchRequestEntry, 0, len(out.Messages)) + for _, msg := range out.Messages { + newLocalMessage := &models.Message{ + Queue: queue, + ExtID: aws.ToString(msg.MessageId), + Received: time.Now(), + Data: aws.ToString(msg.Body), + } + messagesToReturn = append(messagesToReturn, newLocalMessage) + + // Pull the message from the queue + // TODO: should this be determined by the caller? + messagesToDelete = append(messagesToDelete, types.DeleteMessageBatchRequestEntry{ + Id: msg.MessageId, + ReceiptHandle: msg.ReceiptHandle, + }) + } + + if _, err := p.client.DeleteMessageBatch(ctx, &sqs.DeleteMessageBatchInput{ + QueueUrl: aws.String(queue), + Entries: messagesToDelete, + }); err != nil { + log.Printf("error deleting messages from queue: %v", err) + } + + return messagesToReturn, nil +} diff --git a/internal/sqs-browse/providers/stormstore/memstore.go b/internal/sqs-browse/providers/stormstore/memstore.go new file mode 100644 index 0000000..5825003 --- /dev/null +++ b/internal/sqs-browse/providers/stormstore/memstore.go @@ -0,0 +1,31 @@ +package stormstore + +import ( + "context" + + "github.com/asdine/storm" + "github.com/lmika/awstools/internal/sqs-browse/models" + "github.com/pkg/errors" +) + +type Store struct { + db *storm.DB +} + +// TODO: should probably be a workspace provider +func NewStore(filename string) (*Store, error) { + db, err := storm.Open(filename) + if err != nil { + return nil, errors.Wrapf(err, "cannot open store %v", filename) + } + + return &Store{db: db}, nil +} + +func (s *Store) Close() { + s.db.Close() +} + +func (s *Store) Save(ctx context.Context, msg *models.Message) error { + return s.db.Save(msg) +} diff --git a/internal/sqs-browse/services/messages/iface.go b/internal/sqs-browse/services/messages/iface.go new file mode 100644 index 0000000..431d6c5 --- /dev/null +++ b/internal/sqs-browse/services/messages/iface.go @@ -0,0 +1,11 @@ +package messages + +import ( + "context" + + "github.com/lmika/awstools/internal/sqs-browse/models" +) + +type MessageSender interface { + SendMessage(ctx context.Context, msg models.Message, queue string) (string, error) +} diff --git a/internal/sqs-browse/services/messages/service.go b/internal/sqs-browse/services/messages/service.go new file mode 100644 index 0000000..5cc63c5 --- /dev/null +++ b/internal/sqs-browse/services/messages/service.go @@ -0,0 +1,26 @@ +package messages + +import ( + "context" + + "github.com/lmika/awstools/internal/sqs-browse/models" + "github.com/pkg/errors" +) + +type Service struct { + messageSender MessageSender +} + +func NewService(messageSender MessageSender) *Service { + return &Service{ + messageSender: messageSender, + } +} + +func (s *Service) SendTo(ctx context.Context, msg models.Message, destQueue string) (string, error) { + messageId, err := s.messageSender.SendMessage(ctx, msg, destQueue) + if err != nil { + return "", errors.Wrapf(err, "cannot send message to %v", destQueue) + } + return messageId, nil +} diff --git a/internal/sqs-browse/services/pollmessage/iface.go b/internal/sqs-browse/services/pollmessage/iface.go new file mode 100644 index 0000000..9a8f7e8 --- /dev/null +++ b/internal/sqs-browse/services/pollmessage/iface.go @@ -0,0 +1,15 @@ +package pollmessage + +import ( + "context" + + "github.com/lmika/awstools/internal/sqs-browse/models" +) + +type MessageStore interface { + Save(ctx context.Context, msg *models.Message) error +} + +type MessagePoller interface { + PollForNewMessages(ctx context.Context, queue string) ([]*models.Message, error) +} diff --git a/internal/sqs-browse/services/pollmessage/service.go b/internal/sqs-browse/services/pollmessage/service.go new file mode 100644 index 0000000..69d2bef --- /dev/null +++ b/internal/sqs-browse/services/pollmessage/service.go @@ -0,0 +1,46 @@ +package pollmessage + +import ( + "context" + "log" + + "github.com/lmika/events" + "github.com/pkg/errors" +) + +type Service struct { + store MessageStore + poller MessagePoller + queue string + bus *events.Bus +} + +func NewService(store MessageStore, poller MessagePoller, queue string, bus *events.Bus) *Service { + return &Service{ + store: store, + poller: poller, + queue: queue, + bus: bus, + } +} + +// Poll starts polling for new messages and adding them to the message store +func (s *Service) Poll(ctx context.Context) error { + for ctx.Err() == nil { + log.Printf("polling for new messages: %v", s.queue) + newMsgs, err := s.poller.PollForNewMessages(ctx, s.queue) + if err != nil { + return errors.Wrap(err, "unable to poll for messages") + } + + for _, msg := range newMsgs { + if err := s.store.Save(ctx, msg); err != nil { + log.Printf("warn: unable to save new message %v", err) + continue + } + } + + s.bus.Fire("new-messages", newMsgs) + } + return nil +} diff --git a/internal/sqs-browse/ui/events.go b/internal/sqs-browse/ui/events.go new file mode 100644 index 0000000..b80dbdc --- /dev/null +++ b/internal/sqs-browse/ui/events.go @@ -0,0 +1,5 @@ +package ui + +import "github.com/lmika/awstools/internal/sqs-browse/models" + +type NewMessagesEvent []*models.Message diff --git a/internal/sqs-browse/ui/model.go b/internal/sqs-browse/ui/model.go new file mode 100644 index 0000000..d5f45df --- /dev/null +++ b/internal/sqs-browse/ui/model.go @@ -0,0 +1,232 @@ +package ui + +import ( + "bytes" + "context" + "encoding/json" + "log" + "strings" + + table "github.com/calyptia/go-bubble-table" + "github.com/charmbracelet/bubbles/textinput" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/common/ui/events" + "github.com/lmika/awstools/internal/common/ui/uimodels" + "github.com/lmika/awstools/internal/sqs-browse/controllers" + "github.com/lmika/awstools/internal/sqs-browse/models" +) + +var ( + activeHeaderStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#ffffff")). + Background(lipgloss.Color("#eac610")) + + inactiveHeaderStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#d1d1d1")) +) + +type uiModel struct { + table table.Model + viewport viewport.Model + + ready bool + tableRows []table.Row + message string + + pendingInput *events.PromptForInput + textInput textinput.Model + + dispatcher *dispatcher.Dispatcher + msgSendingHandlers *controllers.MessageSendingController +} + +func NewModel(dispatcher *dispatcher.Dispatcher, msgSendingHandlers *controllers.MessageSendingController) tea.Model { + tbl := table.New([]string{"seq", "message"}, 100, 20) + rows := make([]table.Row, 0) + tbl.SetRows(rows) + + textInput := textinput.New() + + model := uiModel{ + table: tbl, + tableRows: rows, + message: "", + textInput: textInput, + msgSendingHandlers: msgSendingHandlers, + dispatcher: dispatcher, + } + + return model +} + +func (m uiModel) Init() tea.Cmd { + return nil +} + +func (m *uiModel) updateViewportToSelectedMessage() { + if message, ok := m.selectedMessage(); ok { + // TODO: not all messages are JSON + formattedJson := new(bytes.Buffer) + if err := json.Indent(formattedJson, []byte(message.Data), "", " "); err == nil { + m.viewport.SetContent(formattedJson.String()) + } else { + m.viewport.SetContent(message.Data) + } + } else { + m.viewport.SetContent("(no message selected)") + } +} + +func (m uiModel) selectedMessage() (models.Message, bool) { + if m.ready && len(m.tableRows) > 0 { + if message, ok := m.table.SelectedRow().(messageTableRow); ok { + return models.Message(message), true + } + } + return models.Message{}, false +} + +func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var textInputCommands tea.Cmd + + switch msg := msg.(type) { + // Shared messages + case events.Error: + m.message = "Error: " + msg.Error() + case events.Message: + m.message = string(msg) + case events.PromptForInput: + m.textInput.Focus() + m.textInput.SetValue("") + m.pendingInput = &msg + + // Local messages + case NewMessagesEvent: + for _, newMsg := range msg { + m.tableRows = append(m.tableRows, messageTableRow(*newMsg)) + } + m.table.SetRows(m.tableRows) + m.updateViewportToSelectedMessage() + + case tea.WindowSizeMsg: + fixedViewsHeight := lipgloss.Height(m.headerView()) + lipgloss.Height(m.splitterView()) + lipgloss.Height(m.footerView()) + + if !m.ready { + tableHeight := msg.Height / 2 + + m.table.SetSize(msg.Width, tableHeight) + m.viewport = viewport.New(msg.Width, msg.Height-tableHeight-fixedViewsHeight) + m.viewport.SetContent("(no message selected)") + m.ready = true + log.Println("Viewport is now ready") + } else { + tableHeight := msg.Height / 2 + + m.table.SetSize(msg.Width, tableHeight) + m.viewport.Width = msg.Width + m.viewport.Height = msg.Height - tableHeight - fixedViewsHeight + } + + m.textInput.Width = msg.Width + + m.textInput, textInputCommands = m.textInput.Update(msg) + case tea.KeyMsg: + + // If text input in focus, allow that to accept input messages + if m.pendingInput != nil { + switch msg.String() { + case "ctrl+c", "esc": + m.pendingInput = nil + case "enter": + m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone) + m.pendingInput = nil + default: + m.textInput, textInputCommands = m.textInput.Update(msg) + } + break + } + + // Normal focus + switch msg.String() { + + case "ctrl+c", "q": + return m, tea.Quit + case "up", "i": + m.table.GoUp() + m.updateViewportToSelectedMessage() + case "down", "k": + m.table.GoDown() + m.updateViewportToSelectedMessage() + + // TODO: these should be moved somewhere else + case "f": + if selectedMessage, ok := m.selectedMessage(); ok { + m.dispatcher.Start(context.Background(), m.msgSendingHandlers.ForwardMessage(selectedMessage)) + } + } + default: + m.textInput, textInputCommands = m.textInput.Update(msg) + } + + updatedTable, tableMsgs := m.table.Update(msg) + updatedViewport, viewportMsgs := m.viewport.Update(msg) + + m.table = updatedTable + m.viewport = updatedViewport + + return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs) +} + +func (m uiModel) View() string { + if !m.ready { + return "Initializing" + } + + if m.pendingInput != nil { + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.textInput.View(), + ) + } + + return lipgloss.JoinVertical(lipgloss.Top, + m.headerView(), + m.table.View(), + m.splitterView(), + m.viewport.View(), + m.footerView(), + ) +} + +func (m uiModel) headerView() string { + title := activeHeaderStyle.Render("Queue: XXX") + line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func (m uiModel) splitterView() string { + title := inactiveHeaderStyle.Render("Message") + line := inactiveHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title)))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func (m uiModel) footerView() string { + title := m.message + line := strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))) + return lipgloss.JoinHorizontal(lipgloss.Left, title, line) +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/sqs-browse/ui/tblmodel.go b/internal/sqs-browse/ui/tblmodel.go new file mode 100644 index 0000000..b4fc15c --- /dev/null +++ b/internal/sqs-browse/ui/tblmodel.go @@ -0,0 +1,27 @@ +package ui + +import ( + "fmt" + "io" + "strings" + + table "github.com/calyptia/go-bubble-table" + "github.com/lmika/awstools/internal/sqs-browse/models" +) + +type messageTableRow models.Message + +func (mtr messageTableRow) Render(w io.Writer, model table.Model, index int) { + firstLine := strings.SplitN(string(mtr.Data), "\n", 2)[0] + + sb := strings.Builder{} + sb.WriteString(fmt.Sprintf("%d", mtr.ID)) + sb.WriteString("\t") + sb.WriteString(firstLine) + + if index == model.Cursor() { + fmt.Fprintln(w, model.Styles.SelectedRow.Render(sb.String())) + } else { + fmt.Fprintln(w, sb.String()) + } +} diff --git a/test/cmd/load-test-table/main.go b/test/cmd/load-test-table/main.go new file mode 100644 index 0000000..dfda661 --- /dev/null +++ b/test/cmd/load-test-table/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "log" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/brianvoe/gofakeit/v6" + "github.com/google/uuid" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/lmika/awstools/internal/dynamo-browse/providers/dynamo" + "github.com/lmika/awstools/internal/dynamo-browse/services/tables" + "github.com/lmika/gopkgs/cli" +) + +func main() { + ctx := context.Background() + tableName := "awstools-test" + totalItems := 300 + + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + cli.Fatalf("cannot load AWS config: %v", err) + } + + dynamoClient := dynamodb.NewFromConfig(cfg, + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + + if _, err = dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ + TableName: aws.String(tableName), + }); err != nil { + log.Printf("warn: cannot delete table: %v", tableName) + } + + if _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String(tableName), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange}, + }, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS}, + }, + ProvisionedThroughput: &types.ProvisionedThroughput{ + ReadCapacityUnits: aws.Int64(100), + WriteCapacityUnits: aws.Int64(100), + }, + }); err != nil { + log.Fatalf("warn: cannot create table: %v", tableName) + } + + tableInfo := &models.TableInfo{ + Name: tableName, + Keys: models.KeyAttribute{PartitionKey: "pk", SortKey: "sk"}, + } + + dynamoProvider := dynamo.NewProvider(dynamoClient) + tableService := tables.NewService(dynamoProvider) + + for i := 0; i < totalItems; i++ { + key := uuid.New().String() + if err := tableService.Put(ctx, tableInfo, models.Item{ + "pk": &types.AttributeValueMemberS{Value: key}, + "sk": &types.AttributeValueMemberS{Value: key}, + "name": &types.AttributeValueMemberS{Value: gofakeit.Name()}, + "address": &types.AttributeValueMemberS{Value: gofakeit.Address().Address}, + "city": &types.AttributeValueMemberS{Value: gofakeit.Address().City}, + "phone": &types.AttributeValueMemberS{Value: gofakeit.Phone()}, + "web": &types.AttributeValueMemberS{Value: gofakeit.URL()}, + }); err != nil { + log.Fatalln(err) + } + } + + log.Printf("table '%v' created with %v items", tableName, totalItems) +} diff --git a/test/testdynamo/client.go b/test/testdynamo/client.go new file mode 100644 index 0000000..bb7be11 --- /dev/null +++ b/test/testdynamo/client.go @@ -0,0 +1,63 @@ +package testdynamo + +import ( + "context" + "testing" + + "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/feature/dynamodb/attributevalue" + "github.com/aws/aws-sdk-go-v2/service/dynamodb" + "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" + "github.com/stretchr/testify/assert" +) + +type TestData []map[string]interface{} + +func SetupTestTable(t *testing.T, tableName string, testData TestData) (*dynamodb.Client, func()) { + t.Helper() + ctx := context.Background() + + cfg, err := config.LoadDefaultConfig(ctx, + config.WithRegion("ap-southeast-2"), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("abc", "123", ""))) + assert.NoError(t, err) + + dynamoClient := dynamodb.NewFromConfig(cfg, + dynamodb.WithEndpointResolver(dynamodb.EndpointResolverFromURL("http://localhost:8000"))) + + _, err = dynamoClient.CreateTable(ctx, &dynamodb.CreateTableInput{ + TableName: aws.String(tableName), + KeySchema: []types.KeySchemaElement{ + {AttributeName: aws.String("pk"), KeyType: types.KeyTypeHash}, + {AttributeName: aws.String("sk"), KeyType: types.KeyTypeRange}, + }, + AttributeDefinitions: []types.AttributeDefinition{ + {AttributeName: aws.String("pk"), AttributeType: types.ScalarAttributeTypeS}, + {AttributeName: aws.String("sk"), AttributeType: types.ScalarAttributeTypeS}, + }, + ProvisionedThroughput: &types.ProvisionedThroughput{ + ReadCapacityUnits: aws.Int64(100), + WriteCapacityUnits: aws.Int64(100), + }, + }) + assert.NoError(t, err) + + for _, item := range testData { + m, err := attributevalue.MarshalMap(item) + assert.NoError(t, err) + + _, err = dynamoClient.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: aws.String(tableName), + Item: m, + }) + assert.NoError(t, err) + } + + return dynamoClient, func() { + dynamoClient.DeleteTable(ctx, &dynamodb.DeleteTableInput{ + TableName: aws.String(tableName), + }) + } +} diff --git a/test/testdynamo/helpers.go b/test/testdynamo/helpers.go new file mode 100644 index 0000000..a52c052 --- /dev/null +++ b/test/testdynamo/helpers.go @@ -0,0 +1,16 @@ +package testdynamo + +import ( + "testing" + + "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" + "github.com/lmika/awstools/internal/dynamo-browse/models" + "github.com/stretchr/testify/assert" +) + +func TestRecordAsItem(t *testing.T, item map[string]interface{}) models.Item { + m, err := attributevalue.MarshalMap(item) + assert.NoError(t, err) + + return models.Item(m) +} diff --git a/test/testuictx/testuictx.go b/test/testuictx/testuictx.go new file mode 100644 index 0000000..e5f1b5a --- /dev/null +++ b/test/testuictx/testuictx.go @@ -0,0 +1,22 @@ +package testuictx + +import ( + "context" + + tea "github.com/charmbracelet/bubbletea" + "github.com/lmika/awstools/internal/common/ui/dispatcher" + "github.com/lmika/awstools/internal/common/ui/uimodels" +) + +func New(ctx context.Context) (context.Context, *TestUIContext) { + td := &TestUIContext{} + return uimodels.WithContext(ctx, dispatcher.DispatcherContext{td}), td +} + +type TestUIContext struct { + Messages []tea.Msg +} + +func (t *TestUIContext) Send(msg tea.Msg) { + t.Messages = append(t.Messages, msg) +}