diff --git a/_examples/webui/go.mod b/_examples/webui/go.mod new file mode 100644 index 0000000..e0fc6ca --- /dev/null +++ b/_examples/webui/go.mod @@ -0,0 +1,31 @@ +module github.com/luno/workflow/_examples/webui + +go 1.23.2 + +replace github.com/luno/workflow => ../.. + +replace github.com/luno/workflow/adapters/webui => ../../adapters/webui + +require ( + github.com/luno/workflow v0.0.0-20241017150231-e09bd48815f5 + github.com/luno/workflow/adapters/webui v0.0.0-00010101000000-000000000000 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.20.4 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/stretchr/testify v1.9.0 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect +) diff --git a/_examples/webui/go.sum b/_examples/webui/go.sum new file mode 100644 index 0000000..e5bf0dd --- /dev/null +++ b/_examples/webui/go.sum @@ -0,0 +1,47 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +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/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= +github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 h1:MDF6h2H/h4tbzmtIKTuctcwZmY0tY9mD9fNT47QO6HI= +k8s.io/utils v0.0.0-20240921022957-49e7df575cb6/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/_examples/webui/main.go b/_examples/webui/main.go new file mode 100644 index 0000000..427c3b1 --- /dev/null +++ b/_examples/webui/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "strconv" + + "github.com/luno/workflow" + "github.com/luno/workflow/adapters/memrecordstore" + "github.com/luno/workflow/adapters/memrolescheduler" + "github.com/luno/workflow/adapters/memstreamer" + "github.com/luno/workflow/adapters/webui" +) + +func main() { + recordStore := memrecordstore.New() + w := Example(recordStore) + ctx := context.Background() + w.Run(ctx) + defer w.Stop() + + seed := []string{ + "Customer 1", + "Customer 2", + "Customer 3", + } + for _, foreignID := range seed { + _, err := w.Trigger(ctx, foreignID, StatusStart) + if err != nil { + panic(err) + } + } + + paths := webui.Paths{ + List: "/api/v1/list", + Update: "/api/v1/record/update", + ObjectData: "/api/v1/record/objectdata", + } + http.HandleFunc("/", webui.HomeHandlerFunc(paths)) + http.HandleFunc(paths.List, webui.ListHandlerFunc( + recordStore, + func(workflowName string, enumValue int) string { + if workflowName == ExampleWorkflowName { + return Status(enumValue).String() + } + + return strconv.Itoa(enumValue) + }, + )) + http.HandleFunc(paths.ObjectData, webui.ObjectDataHandlerFunc(recordStore)) + http.HandleFunc(paths.Update, webui.UpdateHandlerFunc(recordStore)) + + err := http.ListenAndServe("localhost:9492", nil) + if err != nil { + panic(err) + } + + os.Exit(0) +} + +type ExampleData struct { + Name string + Age int + City string + Languages []string + Details Details +} + +type Details struct { + Hobby string + Profession string + Experience Experience +} + +type Experience struct { + Years int + Projects []string +} + +type Status int + +const ( + StatusUnknown Status = 0 + StatusStart Status = 1 + StatusMiddle Status = 2 + StatusEnd Status = 3 +) + +func (s Status) String() string { + switch s { + case StatusStart: + return "Start" + case StatusMiddle: + return "Middle" + case StatusEnd: + return "End" + default: + return "Unknown" + } +} + +const ExampleWorkflowName = "example" + +func Example(rs workflow.RecordStore) *workflow.Workflow[ExampleData, Status] { + b := workflow.NewBuilder[ExampleData, Status](ExampleWorkflowName) + b.AddStep( + StatusStart, + func(ctx context.Context, r *workflow.Run[ExampleData, Status]) (Status, error) { + return StatusMiddle, nil + }, + StatusMiddle, + ) + b.AddStep( + StatusMiddle, + func(ctx context.Context, r *workflow.Run[ExampleData, Status]) (Status, error) { + if r.ForeignID == "Customer 2" { + return r.Pause(ctx) + } + + if r.ForeignID == "Customer 1" { + return r.Cancel(ctx) + } + + *r.Object = ExampleData{ + Name: "Andrew", + City: "New York", + Languages: []string{"Go", "C++", "Typescript"}, + Details: Details{ + Hobby: "Coding", + Profession: "Software Crafting", + Experience: Experience{ + Years: 1_000_000, + Projects: []string{ + "Workflow", + }, + }, + }, + } + + return StatusEnd, nil + }, + StatusEnd, + ) + + b.OnComplete(func(ctx context.Context, record *workflow.TypedRecord[ExampleData, Status]) error { + fmt.Println("Completed: ", record.ForeignID) + return nil + }) + + b.OnCancel(func(ctx context.Context, record *workflow.TypedRecord[ExampleData, Status]) error { + fmt.Println("Cancelled: ", record.ForeignID) + return nil + }) + + b.OnPause(func(ctx context.Context, record *workflow.TypedRecord[ExampleData, Status]) error { + fmt.Println("Paused: ", record.ForeignID) + return nil + }) + + return b.Build( + memstreamer.New(), + rs, + memrolescheduler.New(), + ) +} diff --git a/adapters/adaptertest/recordstore.go b/adapters/adaptertest/recordstore.go index e75ad2f..67882f2 100644 --- a/adapters/adaptertest/recordstore.go +++ b/adapters/adaptertest/recordstore.go @@ -34,7 +34,7 @@ func testLatest(t *testing.T, factory func() workflow.RecordStore) { t.Run("Latest", func(t *testing.T) { store := factory() ctx := context.Background() - expected := dummyWireRecordWithID(t, 1) + expected := dummyWireRecordWithID(t, "my_workflow", 1) maker := func(recordID int64) (workflow.OutboxEventData, error) { return workflow.OutboxEventData{}, nil } err := store.Store(ctx, expected, maker) @@ -60,7 +60,7 @@ func testLookup(t *testing.T, factory func() workflow.RecordStore) { t.Run("Lookup", func(t *testing.T) { store := factory() ctx := context.Background() - expected := dummyWireRecordWithID(t, 1) + expected := dummyWireRecordWithID(t, "my_workflow", 1) maker := func(recordID int64) (workflow.OutboxEventData, error) { return workflow.OutboxEventData{}, nil } err := store.Store(ctx, expected, maker) @@ -77,7 +77,7 @@ func testStore(t *testing.T, factory func() workflow.RecordStore) { t.Run("RecordStore", func(t *testing.T) { store := factory() ctx := context.Background() - expected := dummyWireRecordWithID(t, 1) + expected := dummyWireRecordWithID(t, "my_workflow", 1) maker := func(recordID int64) (workflow.OutboxEventData, error) { return workflow.OutboxEventData{}, nil } err := store.Store(ctx, expected, maker) @@ -111,7 +111,7 @@ func testListOutboxEvents(t *testing.T, factory func() workflow.RecordStore) { t.Run("ListOutboxEvents", func(t *testing.T) { store := factory() ctx := context.Background() - expected := dummyWireRecord(t) + expected := dummyWireRecord(t, "my_workflow") maker := func(recordID int64) (workflow.OutboxEventData, error) { // Record ID would not have been set if it is a new record. Assign the recordID that the Store provides @@ -145,7 +145,7 @@ func testDeleteOutboxEvent(t *testing.T, factory func() workflow.RecordStore) { t.Run("DeleteOutboxEvent", func(t *testing.T) { store := factory() ctx := context.Background() - expected := dummyWireRecord(t) + expected := dummyWireRecord(t, "my_workflow") maker := func(recordID int64) (workflow.OutboxEventData, error) { // Run ID would not have been set if it is a new record. Assign the recordID that the Store provides @@ -176,55 +176,96 @@ func testDeleteOutboxEvent(t *testing.T, factory func() workflow.RecordStore) { func testList(t *testing.T, factory func() workflow.RecordStore) { workflowName := "my_workflow" + secondWorkflowName := "my_second_workflow" maker := func(recordID int64) (workflow.OutboxEventData, error) { return workflow.OutboxEventData{}, nil } - t.Run("List", func(t *testing.T) { + t.Run("List with no workflow specified returns all", func(t *testing.T) { store := factory() ctx := context.Background() seedCount := 1000 for i := 0; i < seedCount; i++ { - err := store.Store(ctx, dummyWireRecord(t), maker) + name := workflowName + if i > seedCount/2 { + name = secondWorkflowName + } + err := store.Store(ctx, dummyWireRecord(t, name), maker) require.Nil(t, err) } - ls, err := store.List(ctx, workflowName, 0, 53, workflow.OrderTypeAscending) + ls, err := store.List(ctx, "", 0, 53, workflow.OrderTypeAscending) require.Nil(t, err) require.Equal(t, 53, len(ls)) - ls2, err := store.List(ctx, workflowName, 53, 100, workflow.OrderTypeAscending) + ls2, err := store.List(ctx, "", 53, 100, workflow.OrderTypeAscending) require.Nil(t, err) require.Equal(t, 100, len(ls2)) // Make sure the last of the first page is not the same as the first of the next page require.NotEqual(t, ls[52].ID, ls2[0]) - ls3, err := store.List(ctx, workflowName, 153, seedCount-153, workflow.OrderTypeAscending) + ls3, err := store.List(ctx, "", 153, seedCount-153, workflow.OrderTypeAscending) require.Nil(t, err) require.Equal(t, seedCount-153, len(ls3)) - // Make sure the last of the first page is not the same as the first of the next page + // Make sure the last of the first page is not the same as the first of the next page. require.NotEqual(t, ls3[152].ID, ls3[0]) - // Make sure that if 950 is the offset and we only have 1000 then only 1 item would be returned - lastPageAsc, err := store.List(ctx, workflowName, 950, 1000, workflow.OrderTypeAscending) + // Make sure that if 950 is the offset, and we only have 1000 then only 50 items will be returned. + lastPageAsc, err := store.List(ctx, "", 950, 1000, workflow.OrderTypeAscending) require.Nil(t, err) require.Equal(t, 50, len(lastPageAsc)) require.Equal(t, int64(1000), lastPageAsc[len(lastPageAsc)-1].ID) - lastPageDesc, err := store.List(ctx, workflowName, 950, 1000, workflow.OrderTypeDescending) + lastPageDesc, err := store.List(ctx, "", 950, 1000, workflow.OrderTypeDescending) require.Nil(t, err) require.Equal(t, 50, len(lastPageDesc)) require.Equal(t, int64(1000), lastPageDesc[0].ID) }) + t.Run("List - WorkflowName", func(t *testing.T) { + store := factory() + ctx := context.Background() + config := map[status]int{ + statusStarted: 10, + statusMiddle: 100, + statusEnd: 20, + } + for status, count := range config { + for i := 0; i < count; i++ { + newRecord := dummyWireRecord(t, workflowName) + newRecord.Status = int(status) + + err := store.Store(ctx, newRecord, maker) + require.Nil(t, err) + + secondRecord := dummyWireRecord(t, secondWorkflowName) + newRecord.Status = int(status) + + err = store.Store(ctx, secondRecord, maker) + require.Nil(t, err) + } + } + + for status, count := range config { + ls, err := store.List(ctx, workflowName, 0, 100, workflow.OrderTypeAscending, workflow.FilterByStatus(int64(status))) + require.Nil(t, err) + fmt.Println(count, len(ls)) + require.Equal(t, count, len(ls)) + + for _, l := range ls { + require.Equal(t, l.Status, int(status)) + } + } + }) + t.Run("List - FilterByForeignID", func(t *testing.T) { store := factory() ctx := context.Background() foreignIDs := []string{"MSDVUI-OBEWF-BYUIOW", "FRELBJK-SRGIUE-RGTJDSF"} for _, foreignID := range foreignIDs { for i := 0; i < 20; i++ { - wr := dummyWireRecord(t) + wr := dummyWireRecord(t, workflowName) wr.ForeignID = foreignID err := store.Store(ctx, wr, maker) @@ -258,7 +299,7 @@ func testList(t *testing.T, factory func() workflow.RecordStore) { } for runState, count := range config { for i := 0; i < count; i++ { - wr := dummyWireRecord(t) + wr := dummyWireRecord(t, workflowName) wr.RunState = runState err := store.Store(ctx, wr, maker) @@ -287,7 +328,7 @@ func testList(t *testing.T, factory func() workflow.RecordStore) { } for status, count := range config { for i := 0; i < count; i++ { - newRecord := dummyWireRecord(t) + newRecord := dummyWireRecord(t, workflowName) newRecord.Status = int(status) err := store.Store(ctx, newRecord, maker) @@ -307,12 +348,11 @@ func testList(t *testing.T, factory func() workflow.RecordStore) { }) } -func dummyWireRecord(t *testing.T) *workflow.Record { - return dummyWireRecordWithID(t, 0) +func dummyWireRecord(t *testing.T, workflowName string) *workflow.Record { + return dummyWireRecordWithID(t, workflowName, 0) } -func dummyWireRecordWithID(t *testing.T, id int64) *workflow.Record { - workflowName := "my_workflow" +func dummyWireRecordWithID(t *testing.T, workflowName string, id int64) *workflow.Record { foreignID := "Andrew Wormald" runID, err := uuid.NewUUID() require.Nil(t, err) diff --git a/adapters/memrecordstore/memrecordstore.go b/adapters/memrecordstore/memrecordstore.go index fdea968..e0d07d5 100644 --- a/adapters/memrecordstore/memrecordstore.go +++ b/adapters/memrecordstore/memrecordstore.go @@ -10,6 +10,8 @@ import ( "github.com/luno/workflow" ) +const defaultListLimit = 25 + func New(opts ...Option) *Store { // Set option defaults opt := options{ @@ -180,31 +182,39 @@ func (s *Store) List(ctx context.Context, workflowName string, offsetID int64, l s.mu.Lock() defer s.mu.Unlock() + if limit == 0 { + limit = defaultListLimit + } + filter := workflow.MakeFilter(filters...) filteredStore := make(map[int64]*workflow.Record) increment := int64(1) - if len(filters) > 0 { - for _, record := range s.store { - if filter.ByForeignID().Enabled && filter.ByForeignID().Value != record.ForeignID { - continue - } - - status := strconv.FormatInt(int64(record.Status), 10) - if filter.ByStatus().Enabled && filter.ByStatus().Value != status { - continue - } - - runState := strconv.FormatInt(int64(record.RunState), 10) - if filter.ByRunState().Enabled && filter.ByRunState().Value != runState { - continue - } - - filteredStore[increment] = record - increment++ + for i := 0; i <= len(s.store); i++ { + record, ok := s.store[int64(i)] + if !ok { + continue + } + + if workflowName != "" && workflowName != record.WorkflowName { + continue + } + + if filter.ByForeignID().Enabled && filter.ByForeignID().Value != record.ForeignID { + continue + } + + status := strconv.FormatInt(int64(record.Status), 10) + if filter.ByStatus().Enabled && filter.ByStatus().Value != status { + continue } - } else { - // If no filters are specified then assign the whole store - filteredStore = s.store + + runState := strconv.FormatInt(int64(record.RunState), 10) + if filter.ByRunState().Enabled && filter.ByRunState().Value != runState { + continue + } + + filteredStore[increment] = record + increment++ } var ( diff --git a/adapters/sqlstore/sqlstore.go b/adapters/sqlstore/sqlstore.go index e092b67..4adac2c 100644 --- a/adapters/sqlstore/sqlstore.go +++ b/adapters/sqlstore/sqlstore.go @@ -3,12 +3,12 @@ package sqlstore import ( "context" "database/sql" - "fmt" - "github.com/luno/jettison/errors" "github.com/luno/workflow" ) +const defaultListLimit = 25 + type SQLStore struct { writer *sql.DB reader *sql.DB @@ -122,18 +122,40 @@ func (s *SQLStore) DeleteOutboxEvent(ctx context.Context, id int64) error { func (s *SQLStore) List(ctx context.Context, workflowName string, offsetID int64, limit int, order workflow.OrderType, filters ...workflow.RecordFilter) ([]workflow.Record, error) { filter := workflow.MakeFilter(filters...) - var filterStr string + var ( + filterStr string + filterParams []any + ) if filter.ByForeignID().Enabled { - filterStr += fmt.Sprintf(" and foreign_id='%v' ", filter.ByForeignID().Value) + filterStr += " and foreign_id=? " + filterParams = append(filterParams, filter.ByForeignID().Value) } if filter.ByStatus().Enabled { - filterStr += fmt.Sprintf(" and status=%v ", filter.ByStatus().Value) + filterStr += " and status=? " + filterParams = append(filterParams, filter.ByStatus().Value) } if filter.ByRunState().Enabled { - filterStr += fmt.Sprintf(" and run_state=%v ", filter.ByRunState().Value) + filterStr += " and run_state=? " + filterParams = append(filterParams, filter.ByRunState().Value) + } + + if limit == 0 { + limit = defaultListLimit + } + + var params []any + if workflowName == "" { + params = append(params, offsetID) + params = append(params, filterParams...) + params = append(params, limit) + return s.listWhere(ctx, s.reader, "id>? "+filterStr+"order by id "+order.String()+" limit ?", params...) } - return s.listWhere(ctx, s.reader, "workflow_name=? and id>? "+filterStr+"order by id "+order.String()+" limit ?", workflowName, offsetID, limit) + params = append(params, workflowName) + params = append(params, offsetID) + params = append(params, filterParams...) + params = append(params, limit) + return s.listWhere(ctx, s.reader, "workflow_name=? and id>? "+filterStr+"order by id "+order.String()+" limit ?", params...) } diff --git a/adapters/webui/go.mod b/adapters/webui/go.mod new file mode 100644 index 0000000..5dd255c --- /dev/null +++ b/adapters/webui/go.mod @@ -0,0 +1,28 @@ +module github.com/luno/workflow/adapters/webui + +go 1.23.2 + +replace github.com/luno/workflow => ../.. + +require ( + github.com/luno/workflow v0.0.0-20241017150231-e09bd48815f5 + github.com/stretchr/testify v1.9.0 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_golang v1.20.4 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + golang.org/x/sys v0.22.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 // indirect +) diff --git a/adapters/webui/go.sum b/adapters/webui/go.sum new file mode 100644 index 0000000..e5bf0dd --- /dev/null +++ b/adapters/webui/go.sum @@ -0,0 +1,47 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +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/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= +github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/utils v0.0.0-20240921022957-49e7df575cb6 h1:MDF6h2H/h4tbzmtIKTuctcwZmY0tY9mD9fNT47QO6HI= +k8s.io/utils v0.0.0-20240921022957-49e7df575cb6/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= diff --git a/adapters/webui/internal/api/handlers.go b/adapters/webui/internal/api/handlers.go new file mode 100644 index 0000000..368b0b7 --- /dev/null +++ b/adapters/webui/internal/api/handlers.go @@ -0,0 +1,3 @@ +package api + +type Stringer func(workflowName string, enumValue int) string diff --git a/adapters/webui/internal/api/list.go b/adapters/webui/internal/api/list.go new file mode 100644 index 0000000..2bd9b63 --- /dev/null +++ b/adapters/webui/internal/api/list.go @@ -0,0 +1,107 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "time" + + "github.com/luno/workflow" +) + +type ListRequest struct { + WorkflowName string `json:"workflow_name"` + OffsetID int64 `json:"offset_id"` + Limit int `json:"limit"` + Order string `json:"order"` + FilterByForeignID string `json:"filter_by_foreign_id"` + FilterByRunState int `json:"filter_by_run_state"` + FilterByStatus int `json:"filter_by_status"` +} + +type ListResponse struct { + Items []ListItem `json:"items"` +} + +// ListItem is a lightweight version of workflow.Record +type ListItem struct { + ID int64 `json:"id"` + WorkflowName string `json:"workflow_name"` + ForeignID string `json:"foreign_id"` + RunID string `json:"run_id"` + RunState string `json:"run_state"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListWorkflowRecords func(ctx context.Context, workflowName string, offsetID int64, limit int, order workflow.OrderType, filters ...workflow.RecordFilter) ([]workflow.Record, error) + +func List(listRecords ListWorkflowRecords, stringer Stringer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Bad Request: cannot read body", http.StatusBadRequest) + return + } + + var req ListRequest + err = json.Unmarshal(body, &req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + order := workflow.OrderTypeAscending + if req.Order == "desc" { + order = workflow.OrderTypeDescending + } + + var filters []workflow.RecordFilter + if req.FilterByRunState != 0 { + filters = append(filters, workflow.FilterByRunState(workflow.RunState(req.FilterByRunState))) + } + + if req.FilterByForeignID != "" { + filters = append(filters, workflow.FilterByForeignID(req.FilterByForeignID)) + } + + if req.FilterByStatus != 0 { + filters = append(filters, workflow.FilterByStatus(int64(req.FilterByStatus))) + } + + list, err := listRecords(r.Context(), req.WorkflowName, req.OffsetID, req.Limit, order, filters...) + if err != nil { + http.Error(w, "failed to collect records from store", http.StatusInternalServerError) + return + } + + var listItems []ListItem + for _, record := range list { + statusName := stringer(record.WorkflowName, record.Status) + listItems = append(listItems, ListItem{ + ID: record.ID, + WorkflowName: record.WorkflowName, + ForeignID: record.ForeignID, + RunID: record.RunID, + RunState: record.RunState.String(), + Status: statusName, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }) + } + + resp := ListResponse{ + Items: listItems, + } + + b, err := json.MarshalIndent(resp, " ", " ") + if err != nil { + http.Error(w, "failed to json marshal list of records", http.StatusInternalServerError) + return + } + + _, _ = w.Write(b) + } +} diff --git a/adapters/webui/internal/api/list_test.go b/adapters/webui/internal/api/list_test.go new file mode 100644 index 0000000..9df48f3 --- /dev/null +++ b/adapters/webui/internal/api/list_test.go @@ -0,0 +1,248 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/luno/workflow" + "github.com/stretchr/testify/require" + + "github.com/luno/workflow/adapters/webui/internal/api" +) + +func TestListHandler(t *testing.T) { + testCases := []struct { + name string + request api.ListRequest + listResponse []workflow.Record + expectedResponse api.ListResponse + expectedStatusCode int + }{ + { + name: "Golden path - asc", + request: api.ListRequest{ + WorkflowName: "test", + OffsetID: 0, + Limit: 5, + Order: "asc", + }, + listResponse: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + }, + expectedResponse: api.ListResponse{ + Items: []api.ListItem{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted.String(), + Status: "9", + }, + }, + }, + expectedStatusCode: 200, + }, + { + name: "Golden path - desc", + request: api.ListRequest{ + WorkflowName: "test", + OffsetID: 0, + Limit: 5, + Order: "desc", + }, + listResponse: []workflow.Record{ + { + ID: 2, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + }, + expectedResponse: api.ListResponse{ + Items: []api.ListItem{ + { + ID: 2, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted.String(), + Status: "9", + }, + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted.String(), + Status: "9", + }, + }, + }, + expectedStatusCode: 200, + }, + { + name: "Filter by RunState", + request: api.ListRequest{ + WorkflowName: "test", + OffsetID: 0, + Limit: 5, + Order: "desc", + FilterByRunState: int(workflow.RunStateCompleted), + }, + listResponse: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + }, + expectedResponse: api.ListResponse{ + Items: []api.ListItem{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted.String(), + Status: "9", + }, + }, + }, + expectedStatusCode: 200, + }, + { + name: "Filter by ForeignID", + request: api.ListRequest{ + WorkflowName: "test", + OffsetID: 0, + Limit: 5, + Order: "desc", + FilterByForeignID: "9", + }, + listResponse: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + }, + expectedResponse: api.ListResponse{ + Items: []api.ListItem{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted.String(), + Status: "9", + }, + }, + }, + expectedStatusCode: 200, + }, + { + name: "Filter by Status", + request: api.ListRequest{ + WorkflowName: "test", + OffsetID: 0, + Limit: 5, + Order: "desc", + FilterByStatus: 9, + }, + listResponse: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + }, + expectedResponse: api.ListResponse{ + Items: []api.ListItem{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted.String(), + Status: "9", + }, + }, + }, + expectedStatusCode: 200, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listFn := func(ctx context.Context, workflowName string, offsetID int64, limit int, order workflow.OrderType, filters ...workflow.RecordFilter) ([]workflow.Record, error) { + // Workflow's record store has its own tests and we don't need to test workflow's implementation + // of listing with filters etc. The main thing to test would be to ensure the fields are mapped + // correctly and end the concern there (hand off to the record store implementation). + require.Equal(t, tc.request.WorkflowName, workflowName) + require.Equal(t, tc.request.OffsetID, offsetID) + require.Equal(t, tc.request.Limit, limit) + require.Equal(t, tc.request.Order, order.String()) + + filter := workflow.MakeFilter(filters...) + if tc.request.FilterByRunState != 0 { + require.True(t, filter.ByRunState().Enabled) + require.Equal(t, fmt.Sprintf("%v", tc.request.FilterByRunState), filter.ByRunState().Value) + } + if tc.request.FilterByForeignID != "" { + require.True(t, filter.ByForeignID().Enabled) + require.Equal(t, tc.request.FilterByForeignID, filter.ByForeignID().Value) + } + if tc.request.FilterByStatus != 0 { + require.True(t, filter.ByStatus().Enabled) + require.Equal(t, fmt.Sprintf("%v", tc.request.FilterByStatus), filter.ByStatus().Value) + } + + return tc.listResponse, nil + } + api.List(listFn, func(workflowName string, enumValue int) string { + return "9" + })(w, r) + })) + t.Cleanup(srv.Close) + + body, err := json.Marshal(tc.request) + require.NoError(t, err) + + resp, err := http.Post(srv.URL, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + + require.Equal(t, tc.expectedStatusCode, resp.StatusCode) + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var expectedResp api.ListResponse + err = json.Unmarshal(respBody, &expectedResp) + require.NoError(t, err) + + require.Equal(t, tc.expectedResponse, expectedResp) + }) + } +} diff --git a/adapters/webui/internal/api/objectdata.go b/adapters/webui/internal/api/objectdata.go new file mode 100644 index 0000000..e501fa3 --- /dev/null +++ b/adapters/webui/internal/api/objectdata.go @@ -0,0 +1,41 @@ +package api + +import ( + "context" + "encoding/json" + "io" + "net/http" + + "github.com/luno/workflow" +) + +type ObjectDataRequest struct { + RecordID int64 `json:"record_id"` +} + +type LookupFn func(ctx context.Context, id int64) (*workflow.Record, error) + +func ObjectData(lookup LookupFn) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Bad Request: cannot read body", http.StatusBadRequest) + return + } + + var req ObjectDataRequest + err = json.Unmarshal(body, &req) + if err != nil { + http.Error(w, "Bad Request: cannot unmarshal body", http.StatusBadRequest) + return + } + + record, err := lookup(r.Context(), req.RecordID) + if err != nil { + http.Error(w, "failed to lookup record from store", http.StatusInternalServerError) + return + } + + _, _ = w.Write(record.Object) + } +} diff --git a/adapters/webui/internal/api/objectdata_test.go b/adapters/webui/internal/api/objectdata_test.go new file mode 100644 index 0000000..9845693 --- /dev/null +++ b/adapters/webui/internal/api/objectdata_test.go @@ -0,0 +1,58 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/luno/workflow" + "github.com/stretchr/testify/require" + + "github.com/luno/workflow/adapters/webui/internal/api" +) + +type testObjectData struct { + Name string + Email string +} + +func TestObjectDataHandler(t *testing.T) { + expectedResponseData := testObjectData{ + Name: "Andrew Wormald", + Email: "andrew@workflow.com", + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lookupFn := func(ctx context.Context, id int64) (*workflow.Record, error) { + b, err := workflow.Marshal(&expectedResponseData) + require.NoError(t, err) + + return &workflow.Record{ + Object: b, + }, nil + } + api.ObjectData(lookupFn)(w, r) + })) + t.Cleanup(srv.Close) + + body, err := json.Marshal(api.ObjectDataRequest{RecordID: 1}) + require.NoError(t, err) + + resp, err := http.Post(srv.URL, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + + require.Equal(t, 200, resp.StatusCode) + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + var actualResp testObjectData + err = json.Unmarshal(respBody, &actualResp) + require.NoError(t, err) + + require.Equal(t, expectedResponseData, actualResp) +} diff --git a/adapters/webui/internal/api/update.go b/adapters/webui/internal/api/update.go new file mode 100644 index 0000000..c030afb --- /dev/null +++ b/adapters/webui/internal/api/update.go @@ -0,0 +1,73 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + + "github.com/luno/workflow" +) + +type UpdateRequest struct { + RecordID int64 `json:"record_id"` + Action string `json:"action"` +} + +func Update(store workflow.RecordStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + // NoReturnErr: HTTP api. + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + + var req UpdateRequest + err = json.Unmarshal(body, &req) + if err != nil { + http.Error(w, "Bad Request: cannot unmarshal body", http.StatusBadRequest) + return + } + + wr, err := store.Lookup(r.Context(), req.RecordID) + if err != nil { + http.Error(w, "failed to lookup record from store", http.StatusInternalServerError) + return + } + + ctr := workflow.NewRunStateController(store.Store, wr) + if err != nil { + http.Error(w, "failed to build controller for record", http.StatusInternalServerError) + return + } + + switch req.Action { + case "pause": + err = ctr.Pause(r.Context()) + if err != nil { + http.Error(w, "failed to pause record", http.StatusInternalServerError) + return + } + case "resume": + err = ctr.Resume(r.Context()) + if err != nil { + http.Error(w, "failed to resume record", http.StatusInternalServerError) + return + } + case "cancel": + err = ctr.Cancel(r.Context()) + if err != nil { + http.Error(w, "failed to cancel record", http.StatusInternalServerError) + return + } + case "delete": + err = ctr.DeleteData(r.Context()) + if err != nil { + http.Error(w, "failed to delete data of record", http.StatusInternalServerError) + return + } + default: + http.Error(w, "unknown action provided", http.StatusInternalServerError) + } + } +} diff --git a/adapters/webui/internal/api/update_test.go b/adapters/webui/internal/api/update_test.go new file mode 100644 index 0000000..ca3c2ee --- /dev/null +++ b/adapters/webui/internal/api/update_test.go @@ -0,0 +1,185 @@ +package api_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/luno/workflow" + "github.com/luno/workflow/adapters/memrecordstore" + "github.com/stretchr/testify/require" + + "github.com/luno/workflow/adapters/webui/internal/api" +) + +func TestUpdateHandler(t *testing.T) { + testCases := []struct { + name string + request api.UpdateRequest + before []workflow.Record + after []workflow.Record + expectedStatusCode int + }{ + { + name: "Pause", + request: api.UpdateRequest{ + RecordID: 1, + Action: "pause", + }, + before: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateRunning, + Status: 2, + }, + }, + after: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStatePaused, + Status: 2, + }, + }, + expectedStatusCode: 200, + }, + { + name: "Resume", + request: api.UpdateRequest{ + RecordID: 1, + Action: "resume", + }, + before: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStatePaused, + Status: 2, + }, + }, + after: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateRunning, + Status: 2, + }, + }, + expectedStatusCode: 200, + }, + { + name: "Cancel", + request: api.UpdateRequest{ + RecordID: 1, + Action: "cancel", + }, + before: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateRunning, + Status: 2, + }, + }, + after: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCancelled, + Status: 2, + }, + }, + expectedStatusCode: 200, + }, + { + name: "Delete", + request: api.UpdateRequest{ + RecordID: 1, + Action: "delete", + }, + before: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + }, + after: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateRequestedDataDeleted, + Status: 9, + Object: []byte("Deleted"), + }, + }, + expectedStatusCode: 200, + }, + { + name: "Unknown action", + request: api.UpdateRequest{ + RecordID: 1, + Action: "", + }, + before: []workflow.Record{ + { + ID: 1, + WorkflowName: "test", + ForeignID: "9", + RunState: workflow.RunStateCompleted, + Status: 9, + }, + }, + expectedStatusCode: 500, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + recordStore := memrecordstore.New() + + ctx := context.Background() + for _, record := range tc.before { + err := recordStore.Store(ctx, &record, func(recordID int64) (workflow.OutboxEventData, error) { + return workflow.OutboxEventData{}, nil + }) + require.NoError(t, err) + } + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + api.Update(recordStore)(w, r) + })) + t.Cleanup(srv.Close) + + body, err := json.Marshal(tc.request) + require.NoError(t, err) + + resp, err := http.Post(srv.URL, "application/json", bytes.NewReader(body)) + require.NoError(t, err) + + require.Equal(t, tc.expectedStatusCode, resp.StatusCode) + + for _, expected := range tc.after { + actual, err := recordStore.Lookup(ctx, expected.ID) + require.NoError(t, err) + + // No need to compare objects + expected.Object = actual.Object + require.Equal(t, expected, *actual) + } + }) + } +} diff --git a/adapters/webui/internal/frontend/home.go b/adapters/webui/internal/frontend/home.go new file mode 100644 index 0000000..f3a6e1f --- /dev/null +++ b/adapters/webui/internal/frontend/home.go @@ -0,0 +1,62 @@ +package frontend + +import ( + _ "embed" + "html/template" + "net/http" + "strings" +) + +// Embed the template file +// +//go:embed home.html +var homeTemplate string + +//go:embed main.js +var mainJS string + +func HomeHandlerFunc(paths Paths) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + t, err := template.New("home.html").Parse(homeTemplate) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + jsTemplate, err := template.New("main.js").Parse(mainJS) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Build the JS using the provided paths + var builder strings.Builder + err = jsTemplate.Execute(&builder, struct { + Paths Paths + }{ + Paths: paths, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + err = t.Execute(w, struct { + Paths Paths + Javascript template.JS + }{ + Paths: paths, + Javascript: template.JS(builder.String()), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +type Paths struct { + List string + Update string + ObjectData string +} diff --git a/adapters/webui/internal/frontend/home.html b/adapters/webui/internal/frontend/home.html new file mode 100644 index 0000000..aba88ae --- /dev/null +++ b/adapters/webui/internal/frontend/home.html @@ -0,0 +1,120 @@ + + +
+ + + +ID | +Foreign ID | +Run ID | +Run State | +Status | +Created At | +Updated At | +Object | +Actions | +
---|