Merge pull request #1 from lmika/feature/sqs-browse

Added sqs-browse and dynamo-browse
This commit is contained in:
Leon Mika 2022-03-26 07:14:24 +11:00 committed by GitHub
commit 33115c7c13
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 2888 additions and 18 deletions

37
.github/workflows/ci.yaml vendored Normal file
View file

@ -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/*"

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
debug.log

80
cmd/dynamo-browse/main.go Normal file
View file

@ -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)
}

94
cmd/sqs-browse/main.go Normal file
View file

@ -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)
}

View file

@ -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"
)
@ -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)

54
go.mod
View file

@ -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
)

103
go.sum
View file

@ -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=

View file

@ -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:]))
})
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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,
})
}

View file

@ -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
}()
}

View file

@ -0,0 +1,7 @@
package dispatcher
import tea "github.com/charmbracelet/bubbletea"
type MessagePublisher interface {
Send(msg tea.Msg)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
})
}

View file

@ -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",
},
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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]
}

View file

@ -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"},
},
}

View file

@ -0,0 +1,12 @@
package models
type TableInfo struct {
Name string
Keys KeyAttribute
DefinedAttributes []string
}
type KeyAttribute struct {
PartitionKey string
SortKey string
}

View file

@ -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")
}

View file

@ -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",
},
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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",
},
}

View file

@ -0,0 +1,7 @@
package ui
import tea "github.com/charmbracelet/bubbletea"
type MessagePublisher interface {
Send(msg tea.Msg)
}

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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
})
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -0,0 +1,5 @@
package ui
import "github.com/lmika/awstools/internal/sqs-browse/models"
type NewMessagesEvent []*models.Message

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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)
}

63
test/testdynamo/client.go Normal file
View file

@ -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),
})
}
}

View file

@ -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)
}

View file

@ -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)
}