sqs-browse: started working on put commands

This commit is contained in:
Leon Mika 2022-03-24 12:54:32 +11:00
parent 43680000a8
commit cecdbafabb
17 changed files with 339 additions and 26 deletions

View file

@ -7,7 +7,9 @@ import (
"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"
@ -45,7 +47,13 @@ func main() {
tableReadController := controllers.NewTableReadController(tableService, *flagTable)
tableWriteController := controllers.NewTableWriteController(tableService, tableReadController, *flagTable)
uiModel := ui.NewModel(uiDispatcher, tableReadController, tableWriteController)
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

2
go.mod
View file

@ -20,6 +20,7 @@ require (
)
require (
github.com/alecthomas/participle/v2 v2.0.0-alpha7 // 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.6 // indirect
@ -36,6 +37,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // 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

8
go.sum
View file

@ -1,3 +1,7 @@
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/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=
@ -75,6 +79,8 @@ github.com/lmika/events v0.0.0-20200906102219-a2269cd4394e h1:0QkUe2ejnT/i+xbgGy
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=
@ -106,6 +112,7 @@ 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=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -121,6 +128,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,48 @@
package commandctrl
import (
"context"
"github.com/lmika/awstools/internal/common/ui/events"
"github.com/lmika/awstools/internal/common/ui/uimodels"
"github.com/lmika/shellwords"
"github.com/pkg/errors"
"strings"
)
type CommandController struct {
commands map[string]uimodels.Operation
}
func NewCommandController(commands map[string]uimodels.Operation) *CommandController {
return &CommandController{
commands: commands,
}
}
func (c *CommandController) Prompt() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
uiCtx.Send(events.PromptForInput{
Prompt: ":",
OnDone: c.Execute(),
})
return nil
})
}
func (c *CommandController) Execute() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
input := strings.TrimSpace(uimodels.PromptValue(ctx))
if input == "" {
return nil
}
tokens := shellwords.Split(input)
command, ok := c.commands[tokens[0]]
if !ok {
return errors.New("no such command: " + tokens[0])
}
return command.Execute(WithCommandArgs(ctx, tokens[1:]))
})
}

View file

@ -0,0 +1,25 @@
package commandctrl_test
import (
"context"
"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"
"testing"
)
func TestCommandController_Prompt(t *testing.T) {
t.Run("prompt user for a command", func(t *testing.T) {
cmd := commandctrl.NewCommandController()
ctx, uiCtx := testuictx.New(context.Background())
err := cmd.Prompt().Execute(ctx)
assert.NoError(t, err)
promptMsg, ok := uiCtx.Messages[0].(events.PromptForInput)
assert.True(t, ok)
assert.Equal(t, ":", promptMsg.Prompt)
})
}

View file

@ -0,0 +1,15 @@
package commandctrl
import "context"
type commandArgContextKeyType struct {}
var commandArgContextKey = commandArgContextKeyType{}
func WithCommandArgs(ctx context.Context, args []string) context.Context {
return context.WithValue(ctx, commandArgContextKey, args)
}
func CommandArgs(ctx context.Context) []string {
args, _ := ctx.Value(commandArgContextKey).([]string)
return args
}

View file

@ -3,6 +3,7 @@ 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"
)
@ -21,12 +22,67 @@ func NewTableWriteController(tableService *tables.Service, tableReadControllers
}
}
func (c *TableWriteController) EnableReadWrite() uimodels.Operation {
func (c *TableWriteController) ToggleReadWrite() uimodels.Operation {
return uimodels.OperationFn(func(ctx context.Context) error {
uiCtx := uimodels.Ctx(ctx)
uiCtx.Send(SetReadWrite{NewValue: true})
uiCtx.Message("read/write mode enabled")
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")
}
// Delete the item
if err := c.tableService.Put(ctx, c.tableName, 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
})
}

View file

@ -13,21 +13,33 @@ import (
"testing"
)
func TestTableWriteController_EnableReadWrite(t *testing.T) {
func TestTableWriteController_ToggleReadWrite(t *testing.T) {
twc, _, closeFn := setupController(t)
t.Cleanup(closeFn)
t.Run("should send event enabling read write", func(t *testing.T) {
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.EnableReadWrite().Execute(ctx)
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) {

View file

@ -9,3 +9,15 @@ type ResultSet struct {
}
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
}

View file

@ -0,0 +1,39 @@
package modexpr
import (
"github.com/alecthomas/participle/v2"
"github.com/aws/aws-sdk-go-v2/service/dynamodb/types"
"github.com/pkg/errors"
"strconv"
)
type astExpr struct {
Attributes []*astAttribute `parser:"@@ (',' @@)*"`
}
type astAttribute struct {
Name string `parser:"@Ident '='"`
Value string `parser:"@String"`
}
func (a astAttribute) dynamoValue() (types.AttributeValue, error) {
// TODO: should be based on type
s, err := strconv.Unquote(a.Value)
if err != nil {
return nil, errors.Wrap(err, "cannot unquote string")
}
return &types.AttributeValueMemberS{Value: s}, nil
}
var parser = participle.MustBuild(&astExpr{})
func Parse(expr string) (*ModExpr, error) {
var ast astExpr
if err := parser.ParseString("expr", expr, &ast); err != nil {
return nil, errors.Wrapf(err, "cannot parse expression: '%v'", expr)
}
return &ModExpr{ast: &ast}, nil
}

View file

@ -0,0 +1,22 @@
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) {
newItem := item.Clone()
for _, attribute := range me.ast.Attributes {
var err error
name := attribute.Name
newItem[name], err = attribute.dynamoValue()
if err != nil {
return nil, err
}
}
return newItem, nil
}

View file

@ -0,0 +1,39 @@
package modexpr_test
import (
"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"
"testing"
)
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)
})
}

View file

@ -13,6 +13,17 @@ type Provider struct {
client *dynamodb.Client
}
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}
}

View file

@ -9,4 +9,5 @@ import (
type TableProvider interface {
ScanItems(ctx context.Context, tableName string) ([]models.Item, error)
DeleteItem(ctx context.Context, tableName string, key map[string]types.AttributeValue) error
PutItem(ctx context.Context, name string, item models.Item) error
}

View file

@ -60,6 +60,10 @@ func (s *Service) Scan(ctx context.Context, table string) (*models.ResultSet, er
}, nil
}
func (s *Service) Put(ctx context.Context, tableName string, item models.Item) error {
return s.provider.PutItem(ctx, tableName, item)
}
func (s *Service) Delete(ctx context.Context, name string, item models.Item) error {
// TODO: do not hardcode keys
return s.provider.DeleteItem(ctx, name, map[string]types.AttributeValue{

View file

@ -9,6 +9,7 @@ import (
"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"
@ -19,13 +20,13 @@ import (
var (
activeHeaderStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
Bold(true).
Foreground(lipgloss.Color("#ffffff")).
Background(lipgloss.Color("#4479ff"))
inactiveHeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#d1d1d1"))
)
type uiModel struct {
@ -34,20 +35,21 @@ type uiModel struct {
tableWidth, tableHeight int
ready bool
ready bool
//resultSet *models.ResultSet
state controllers.State
message string
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, tableReadController *controllers.TableReadController, tableWriteController *controllers.TableWriteController) tea.Model {
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)
@ -60,6 +62,7 @@ func NewModel(dispatcher *dispatcher.Dispatcher, tableReadController *controller
textInput: textInput,
dispatcher: dispatcher,
commandController: commandController,
tableReadController: tableReadController,
tableWriteController: tableWriteController,
}
@ -174,7 +177,7 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case "ctrl+c", "esc":
m.pendingInput = nil
case "enter":
m.dispatcher.Start(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
m.invokeOperation(uimodels.WithPromptValue(context.Background(), m.textInput.Value()), m.pendingInput.OnDone)
m.pendingInput = nil
default:
m.textInput, textInputCommands = m.textInput.Update(msg)
@ -193,12 +196,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.updateViewportToSelectedMessage()
// TODO: these should be moved somewhere else
case ":":
m.invokeOperation(context.Background(), m.commandController.Prompt())
case "s":
m.invokeOperation(m.tableReadController.Scan())
m.invokeOperation(context.Background(), m.tableReadController.Scan())
case "D":
m.invokeOperation(m.tableWriteController.Delete())
case "w":
m.invokeOperation(m.tableWriteController.EnableReadWrite())
m.invokeOperation(context.Background(), m.tableWriteController.Delete())
}
default:
m.textInput, textInputCommands = m.textInput.Update(msg)
@ -213,13 +216,13 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(textInputCommands, tableMsgs, viewportMsgs)
}
func (m uiModel) invokeOperation(op uimodels.Operation) {
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(context.Background(), state)
ctx = controllers.ContextWithState(ctx, state)
m.dispatcher.Start(ctx, op)
}

View file

@ -1,7 +1,9 @@
package ui
import (
"bytes"
"context"
"encoding/json"
table "github.com/calyptia/go-bubble-table"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
@ -67,7 +69,13 @@ func (m uiModel) Init() tea.Cmd {
func (m *uiModel) updateViewportToSelectedMessage() {
if message, ok := m.selectedMessage(); ok {
m.viewport.SetContent(message.Data)
// 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)")
}
@ -204,8 +212,8 @@ func (m uiModel) headerView() string {
}
func (m uiModel) splitterView() string {
title := activeHeaderStyle.Render("Message")
line := activeHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
title := inactiveHeaderStyle.Render("Message")
line := inactiveHeaderStyle.Render(strings.Repeat(" ", max(0, m.viewport.Width-lipgloss.Width(title))))
return lipgloss.JoinHorizontal(lipgloss.Left, title, line)
}