Merge pull request #1 from lmika/feature/sqs-browse
Added sqs-browse and dynamo-browse
This commit is contained in:
commit
33115c7c13
37
.github/workflows/ci.yaml
vendored
Normal file
37
.github/workflows/ci.yaml
vendored
Normal 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
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
debug.log
|
80
cmd/dynamo-browse/main.go
Normal file
80
cmd/dynamo-browse/main.go
Normal 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
94
cmd/sqs-browse/main.go
Normal 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)
|
||||
}
|
|
@ -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
54
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
|
||||
)
|
||||
|
|
103
go.sum
103
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=
|
||||
|
|
49
internal/common/ui/commandctrl/commandctrl.go
Normal file
49
internal/common/ui/commandctrl/commandctrl.go
Normal 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:]))
|
||||
})
|
||||
}
|
26
internal/common/ui/commandctrl/commandctrl_test.go
Normal file
26
internal/common/ui/commandctrl/commandctrl_test.go
Normal 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)
|
||||
})
|
||||
}
|
16
internal/common/ui/commandctrl/context.go
Normal file
16
internal/common/ui/commandctrl/context.go
Normal 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
|
||||
}
|
32
internal/common/ui/dispatcher/context.go
Normal file
32
internal/common/ui/dispatcher/context.go
Normal 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,
|
||||
})
|
||||
}
|
46
internal/common/ui/dispatcher/dispatcher.go
Normal file
46
internal/common/ui/dispatcher/dispatcher.go
Normal 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
|
||||
}()
|
||||
}
|
7
internal/common/ui/dispatcher/iface.go
Normal file
7
internal/common/ui/dispatcher/iface.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package dispatcher
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type MessagePublisher interface {
|
||||
Send(msg tea.Msg)
|
||||
}
|
17
internal/common/ui/events/errors.go
Normal file
17
internal/common/ui/events/errors.go
Normal 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
|
||||
}
|
16
internal/common/ui/uimodels/context.go
Normal file
16
internal/common/ui/uimodels/context.go
Normal 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)
|
||||
}
|
10
internal/common/ui/uimodels/iface.go
Normal file
10
internal/common/ui/uimodels/iface.go
Normal 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)
|
||||
}
|
13
internal/common/ui/uimodels/operations.go
Normal file
13
internal/common/ui/uimodels/operations.go
Normal 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)
|
||||
}
|
16
internal/common/ui/uimodels/promptvalue.go
Normal file
16
internal/common/ui/uimodels/promptvalue.go
Normal 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)
|
||||
}
|
11
internal/dynamo-browse/controllers/events.go
Normal file
11
internal/dynamo-browse/controllers/events.go
Normal 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
|
||||
}
|
28
internal/dynamo-browse/controllers/state.go
Normal file
28
internal/dynamo-browse/controllers/state.go
Normal 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)
|
||||
}
|
66
internal/dynamo-browse/controllers/tableread.go
Normal file
66
internal/dynamo-browse/controllers/tableread.go
Normal 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
|
||||
}
|
134
internal/dynamo-browse/controllers/tablewrite.go
Normal file
134
internal/dynamo-browse/controllers/tablewrite.go
Normal 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
|
||||
})
|
||||
}
|
187
internal/dynamo-browse/controllers/tablewrite_test.go
Normal file
187
internal/dynamo-browse/controllers/tablewrite_test.go
Normal 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",
|
||||
},
|
||||
}
|
44
internal/dynamo-browse/models/attrutils.go
Normal file
44
internal/dynamo-browse/models/attrutils.go
Normal 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
|
||||
}
|
32
internal/dynamo-browse/models/models.go
Normal file
32
internal/dynamo-browse/models/models.go
Normal 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
|
||||
}
|
35
internal/dynamo-browse/models/modexpr/ast.go
Normal file
35
internal/dynamo-browse/models/modexpr/ast.go
Normal 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
|
||||
}
|
31
internal/dynamo-browse/models/modexpr/astmods.go
Normal file
31
internal/dynamo-browse/models/modexpr/astmods.go
Normal 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
|
||||
}
|
21
internal/dynamo-browse/models/modexpr/expr.go
Normal file
21
internal/dynamo-browse/models/modexpr/expr.go
Normal 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
|
||||
}
|
56
internal/dynamo-browse/models/modexpr/expr_test.go
Normal file
56
internal/dynamo-browse/models/modexpr/expr_test.go
Normal 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)
|
||||
})
|
||||
}
|
19
internal/dynamo-browse/models/modexpr/mods.go
Normal file
19
internal/dynamo-browse/models/modexpr/mods.go
Normal 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
|
||||
}
|
16
internal/dynamo-browse/models/modexpr/values.go
Normal file
16
internal/dynamo-browse/models/modexpr/values.go
Normal 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
|
||||
}
|
57
internal/dynamo-browse/models/sorted.go
Normal file
57
internal/dynamo-browse/models/sorted.go
Normal 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]
|
||||
}
|
110
internal/dynamo-browse/models/sorted_test.go
Normal file
110
internal/dynamo-browse/models/sorted_test.go
Normal 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"},
|
||||
},
|
||||
}
|
12
internal/dynamo-browse/models/tableinfo.go
Normal file
12
internal/dynamo-browse/models/tableinfo.go
Normal file
|
@ -0,0 +1,12 @@
|
|||
package models
|
||||
|
||||
type TableInfo struct {
|
||||
Name string
|
||||
Keys KeyAttribute
|
||||
DefinedAttributes []string
|
||||
}
|
||||
|
||||
type KeyAttribute struct {
|
||||
PartitionKey string
|
||||
SortKey string
|
||||
}
|
80
internal/dynamo-browse/providers/dynamo/provider.go
Normal file
80
internal/dynamo-browse/providers/dynamo/provider.go
Normal 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")
|
||||
}
|
118
internal/dynamo-browse/providers/dynamo/provider_test.go
Normal file
118
internal/dynamo-browse/providers/dynamo/provider_test.go
Normal 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",
|
||||
},
|
||||
}
|
15
internal/dynamo-browse/services/tables/iface.go
Normal file
15
internal/dynamo-browse/services/tables/iface.go
Normal 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
|
||||
}
|
79
internal/dynamo-browse/services/tables/service.go
Normal file
79
internal/dynamo-browse/services/tables/service.go
Normal 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))
|
||||
}
|
79
internal/dynamo-browse/services/tables/service_test.go
Normal file
79
internal/dynamo-browse/services/tables/service_test.go
Normal 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",
|
||||
},
|
||||
}
|
7
internal/dynamo-browse/ui/iface.go
Normal file
7
internal/dynamo-browse/ui/iface.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package ui
|
||||
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
type MessagePublisher interface {
|
||||
Send(msg tea.Msg)
|
||||
}
|
289
internal/dynamo-browse/ui/model.go
Normal file
289
internal/dynamo-browse/ui/model.go
Normal 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
|
||||
}
|
41
internal/dynamo-browse/ui/tblmodel.go
Normal file
41
internal/dynamo-browse/ui/tblmodel.go
Normal 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())
|
||||
}
|
||||
}
|
40
internal/sqs-browse/controllers/forward.go
Normal file
40
internal/sqs-browse/controllers/forward.go
Normal 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
|
||||
})
|
||||
}
|
11
internal/sqs-browse/models/message.go
Normal file
11
internal/sqs-browse/models/message.go
Normal 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
|
||||
}
|
78
internal/sqs-browse/providers/sqs/provider.go
Normal file
78
internal/sqs-browse/providers/sqs/provider.go
Normal 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
|
||||
}
|
31
internal/sqs-browse/providers/stormstore/memstore.go
Normal file
31
internal/sqs-browse/providers/stormstore/memstore.go
Normal 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)
|
||||
}
|
11
internal/sqs-browse/services/messages/iface.go
Normal file
11
internal/sqs-browse/services/messages/iface.go
Normal 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)
|
||||
}
|
26
internal/sqs-browse/services/messages/service.go
Normal file
26
internal/sqs-browse/services/messages/service.go
Normal 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
|
||||
}
|
15
internal/sqs-browse/services/pollmessage/iface.go
Normal file
15
internal/sqs-browse/services/pollmessage/iface.go
Normal 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)
|
||||
}
|
46
internal/sqs-browse/services/pollmessage/service.go
Normal file
46
internal/sqs-browse/services/pollmessage/service.go
Normal 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
|
||||
}
|
5
internal/sqs-browse/ui/events.go
Normal file
5
internal/sqs-browse/ui/events.go
Normal file
|
@ -0,0 +1,5 @@
|
|||
package ui
|
||||
|
||||
import "github.com/lmika/awstools/internal/sqs-browse/models"
|
||||
|
||||
type NewMessagesEvent []*models.Message
|
232
internal/sqs-browse/ui/model.go
Normal file
232
internal/sqs-browse/ui/model.go
Normal 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
|
||||
}
|
27
internal/sqs-browse/ui/tblmodel.go
Normal file
27
internal/sqs-browse/ui/tblmodel.go
Normal 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())
|
||||
}
|
||||
}
|
80
test/cmd/load-test-table/main.go
Normal file
80
test/cmd/load-test-table/main.go
Normal 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
63
test/testdynamo/client.go
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
16
test/testdynamo/helpers.go
Normal file
16
test/testdynamo/helpers.go
Normal 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)
|
||||
}
|
22
test/testuictx/testuictx.go
Normal file
22
test/testuictx/testuictx.go
Normal 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)
|
||||
}
|
Loading…
Reference in a new issue