From 638fc3f565c41de6f38aac5fd4c4b0e4226b396e Mon Sep 17 00:00:00 2001 From: Daisuke Maki Date: Wed, 21 Aug 2024 20:29:28 +0900 Subject: [PATCH] Initial code --- .github/workflows/ci.yml | 28 ++++++++++++ .github/workflows/lint.yml | 15 +++++++ .golangci.yml | 89 ++++++++++++++++++++++++++++++++++++ README.md | 60 +++++++++++++++++++++++++ actions/delay.go | 23 ++++++++++ clock/clock.go | 50 +++++++++++++++++++++ go.mod | 11 +++++ go.sum | 10 +++++ httpactions/httpactions.go | 92 ++++++++++++++++++++++++++++++++++++++ log/log.go | 23 ++++++++++ scenario_test.go | 85 +++++++++++++++++++++++++++++++++++ scene/action.go | 31 +++++++++++++ scene/scene.go | 35 +++++++++++++++ scriptor.go | 18 ++++++++ stash/stash.go | 45 +++++++++++++++++++ 15 files changed, 615 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .golangci.yml create mode 100644 README.md create mode 100644 actions/delay.go create mode 100644 clock/clock.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 httpactions/httpactions.go create mode 100644 log/log.go create mode 100644 scenario_test.go create mode 100644 scene/action.go create mode 100644 scene/scene.go create mode 100644 scriptor.go create mode 100644 stash/stash.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31bc665 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,28 @@ +name: CI +on: + - push + - pull_request + +jobs: + build: + runs-on: ubuntu-latest + name: Test + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Install Go stable version + uses: actions/setup-go@v5 + with: + go-version: "1.22" + - name: Run tests + run: go test ./... + diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..8f20ba6 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,15 @@ +name: lint +on: [push] +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + - uses: golangci/golangci-lint-action@v6 + with: + version: v1.60 + args: --timeout=5m diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..fb40ca3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,89 @@ +run: + +linters-settings: + govet: + enable-all: true + disable: + - shadow + - fieldalignment + +linters: + enable-all: true + disable: + - contextcheck + - cyclop + - depguard + - dupl + - exhaustive + - exhaustruct + - err113 + - errorlint + - funlen + - gci + - gochecknoglobals + - gochecknoinits + - gocognit + - gocritic + - gocyclo + - godot + - godox + - gofumpt + - gomnd + - gomoddirectives # I think it's broken + - gosec + - gosmopolitan + - govet + - inamedparam # oh, sod off + - interfacebloat + - ireturn # No, I _LIKE_ returning interfaces + - lll + - maintidx # Do this in code review + - makezero + - mnd # TODO: re-enable when we can check again + - nonamedreturns + - nakedret + - nestif + - nlreturn + - paralleltest + - perfsprint + - testifylint # TODO: re-enable when we can check again + - tagliatelle + - testpackage + - thelper # Tests are fine + - varnamelen # Short names are ok + - wrapcheck + - wsl + +issues: + exclude-rules: + # not needed + - path: /*.go + text: "ST1003: should not use underscores in package names" + linters: + - stylecheck + - path: /*.go + text: "don't use an underscore in package name" + linters: + - revive + - path: /main.go + linters: + - errcheck + - path: /*_test.go + linters: + - errcheck + - errchkjson + - forcetypeassert + - path: /*_example_test.go + linters: + - forbidigo + - path: /*_test.go + text: "var-naming: " + linters: + - revive + + # Maximum issues count per one linter. Set to 0 to disable. Default is 50. + max-issues-per-linter: 0 + + # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. + max-same-issues: 0 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..f3f73ad --- /dev/null +++ b/README.md @@ -0,0 +1,60 @@ +# scriptor + +A "scripting" framework, to coordinate actions: For example, this can be useful to construct testing sequences in Go. + +# HOW TO USE + +## 1. Create a `scene.Scene` object + +```go +s := scene.New() +``` + +## 2. Add `scene.Action` objects to `scene.Scene` + +```go +// inline Action +s.Add(scene.ActionFunc(func(ctx context.Context) error { + // .. do something ... + return nil +})) + +// built-in Action +s.Add(actions.Delay(5*time.Second)) + +// custom Action object +s.Add(myCustomAction{}) +``` + +## 3. Prepare a `context.Context` object + +All transient data in this tool is expected to be passed down along with a `context.Context` object. + +For example, logging is done through a `slog.Logger` passed down with the context. This means that the `context.Context` object must be primed with the logger object before the `Action`s are fired. + +To do this, you can manually create a context object using the appropriate injector functions, such as `log.InjectContext()`: + +```go +ctx := log.InjectContext(context.Background(), slog.New(....)) +``` + +Or, to get the default set of values injected, you can use `scriptor.DefaultContext()`: + +```go +ctx := scriptor.DefaultContext(context.Background()) +``` + +The values that need to be injected defer based on the actions that you provide. +As of this writing the documentation is lacking, but in the future each component +in this module should clearly state which values need to be injected into the +`context.Context` object. + +As of this writing it is safest to just use `scriptor.DefaultContext()` for most everything. + +## 4. Execute the `scene.Scene` object + +Finally, put everything together and execute the scene. + +```go +s.Execute(ctx) +``` \ No newline at end of file diff --git a/actions/delay.go b/actions/delay.go new file mode 100644 index 0000000..a96e286 --- /dev/null +++ b/actions/delay.go @@ -0,0 +1,23 @@ +package actions + +import ( + "context" + "time" + + "github.com/lestrrat-go/scriptor/scene" +) + +type delay time.Duration + +func (d delay) Execute(ctx context.Context) error { + select { + case <-time.After(time.Duration(d)): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func Delay(d time.Duration) scene.Action { + return delay(d) +} diff --git a/clock/clock.go b/clock/clock.go new file mode 100644 index 0000000..549dcf1 --- /dev/null +++ b/clock/clock.go @@ -0,0 +1,50 @@ +package clock + +import ( + "context" + "time" +) + +// Clock is an interface that provides the current time. +type Clock interface { + Now() time.Time +} + +type static time.Time + +func Static(t time.Time) Clock { + return static(t) +} + +func (s static) Now() time.Time { + return time.Time(s) +} + +type realClock struct{} + +func (realClock) Now() time.Time { + return time.Now() +} + +func RealClock() Clock { + return realClock{} +} + +type clockKey struct{} + +func InjectContext(ctx context.Context, v Clock) context.Context { + return context.WithValue(ctx, clockKey{}, v) +} + +// FromContext returns the Clock from the context, if any. +// Make sure to check the return value for nil. +func FromContext(ctx context.Context) Clock { + v := ctx.Value(clockKey{}) + if v == nil { + return nil + } + if c, ok := v.(Clock); ok { + return c + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..557b294 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/lestrrat-go/scriptor + +go 1.22.6 + +require github.com/stretchr/testify v1.9.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..60ce688 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/httpactions/httpactions.go b/httpactions/httpactions.go new file mode 100644 index 0000000..ef4e8bf --- /dev/null +++ b/httpactions/httpactions.go @@ -0,0 +1,92 @@ +package httpactions + +import ( + "context" + "fmt" + "log/slog" + "net/http" + + "github.com/lestrrat-go/scriptor/log" + "github.com/lestrrat-go/scriptor/scene" + "github.com/lestrrat-go/scriptor/stash" +) + +type prevRequestKey struct{} +type prevResponseKey struct{} + +func PrevRequest(ctx context.Context) *http.Request { + st := stash.FromContext(ctx) + if st == nil { + return nil + } + + v, ok := st.Get(prevRequestKey{}) + if !ok { + return nil + } + + if req, ok := v.(*http.Request); ok { + return req + } + return nil +} + +func PrevResponse(ctx context.Context) *http.Response { + st := stash.FromContext(ctx) + if st == nil { + return nil + } + + v, ok := st.Get(prevResponseKey{}) + if !ok { + return nil + } + + if res, ok := v.(*http.Response); ok { + return res + } + return nil +} + +type Client struct { + client *http.Client +} + +func NewClient(client *http.Client) *Client { + return &Client{client: client} +} + +func (h *Client) GetAction(u string) scene.Action { + r, err := http.NewRequest(http.MethodGet, u, nil) + return &httpAction{client: h.client, req: r, err: err, name: "GetAction"} +} + +type httpAction struct { + client *http.Client + req *http.Request + err error + name string +} + +func (h *httpAction) Execute(ctx context.Context) error { + logger := log.FromContext(ctx) + if err := h.err; err != nil { + logger.LogAttrs(ctx, slog.LevelInfo, "actions.httpAction", slog.String("error", err.Error())) + return fmt.Errorf("actions.httpAction: error during setup: %w", err) + } + + req := h.req.WithContext(ctx) + //nolint:bodyclose + res, err := h.client.Do(req) + if err != nil { + return fmt.Errorf("actions.httpAction: error during request: %w", err) + } + + st := stash.FromContext(ctx) + + logger.DebugContext(ctx, "actions.httpAction", slog.Any("request", req)) + logger.DebugContext(ctx, "actions.httpAction", slog.Any("response", res)) + st.Set(prevRequestKey{}, req) + st.Set(prevResponseKey{}, res) + return nil +} diff --git a/log/log.go b/log/log.go new file mode 100644 index 0000000..b5c3de1 --- /dev/null +++ b/log/log.go @@ -0,0 +1,23 @@ +package log + +import ( + "context" + "log/slog" +) + +type logKey struct{} + +func FromContext(ctx context.Context) *slog.Logger { + v := ctx.Value(logKey{}) + if v == nil { + return nil + } + if l, ok := v.(*slog.Logger); ok { + return l + } + return nil +} + +func InjectContext(ctx context.Context, l *slog.Logger) context.Context { + return context.WithValue(ctx, logKey{}, l) +} diff --git a/scenario_test.go b/scenario_test.go new file mode 100644 index 0000000..2341d9d --- /dev/null +++ b/scenario_test.go @@ -0,0 +1,85 @@ +package scriptor_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lestrrat-go/scriptor" + "github.com/lestrrat-go/scriptor/actions" + "github.com/lestrrat-go/scriptor/clock" + "github.com/lestrrat-go/scriptor/httpactions" + "github.com/lestrrat-go/scriptor/scene" + "github.com/stretchr/testify/require" +) + +func TestStaticClock(t *testing.T) { + var observed []time.Time + action := scene.ActionFunc(func(ctx context.Context) error { + cl := clock.FromContext(ctx) + require.NotNil(t, cl, `clock.FromContext(ctx) should not return nil`) + + observed = append(observed, cl.Now()) + return nil + }) + s := scene.New(). + Add(action). + Add(actions.Delay(time.Millisecond * 100)). + Add(action). + Add(actions.Delay(time.Millisecond * 100)). + Add(action) + + now := time.Now() + s.Execute(clock.InjectContext(context.Background(), clock.Static(now))) + + require.Equal(t, 3, len(observed), `expected 3 actions to be executed`) + for _, o := range observed { + require.Equal(t, now, o, `expected all actions to be executed at the same time`) + } +} + +func TestRealClock(t *testing.T) { + var observed []time.Time + action := scene.ActionFunc(func(ctx context.Context) error { + cl := clock.FromContext(ctx) + require.NotNil(t, cl, `clock.FromContext(ctx) should not return nil`) + + observed = append(observed, cl.Now()) + return nil + }) + s := scene.New(). + Add(action). + Add(actions.Delay(time.Millisecond * 100)). + Add(action). + Add(actions.Delay(time.Millisecond * 100)). + Add(action) + + s.Execute(clock.InjectContext(context.Background(), clock.RealClock())) + + require.Equal(t, 3, len(observed), `expected 3 actions to be executed`) + for i := 1; i < len(observed); i++ { + require.True(t, observed[i-1].Before(observed[i]), `expected actions to be executed in order`) + } +} + +func TestHTTP(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + client := httpactions.NewClient(http.DefaultClient) + + s := scene.New(). + Add(client.GetAction(srv.URL)). + Add(scene.ActionFunc(func(ctx context.Context) error { + res := httpactions.PrevResponse(ctx) + defer res.Body.Close() + require.NotNil(t, res, `expected PrevResponse(ctx) to return a non-nil response`) + require.Equal(t, http.StatusOK, res.StatusCode, `expected status code to be 200`) + return nil + })) + + s.Execute(scriptor.DefaultContext(context.Background())) +} diff --git a/scene/action.go b/scene/action.go new file mode 100644 index 0000000..3b60abc --- /dev/null +++ b/scene/action.go @@ -0,0 +1,31 @@ +package scene + +import "context" + +type Action interface { + Execute(context.Context) error +} + +type ActionFunc func(context.Context) error + +func (f ActionFunc) Execute(ctx context.Context) error { + return f(ctx) +} + +type repeater struct { + action Action + count int +} + +func Repeat(a Action, n int) Action { + return &repeater{action: a, count: n} +} + +func (r *repeater) Execute(ctx context.Context) error { + for range r.count { + if err := r.action.Execute(ctx); err != nil { + return err + } + } + return nil +} diff --git a/scene/scene.go b/scene/scene.go new file mode 100644 index 0000000..7ca8549 --- /dev/null +++ b/scene/scene.go @@ -0,0 +1,35 @@ +package scene + +import ( + "context" + "errors" +) + +var ErrEndOfScene = errors.New("end of scene") + +type Scene struct { + actions []Action +} + +func New() *Scene { + return &Scene{} +} + +func (s *Scene) Add(a Action) *Scene { + s.actions = append(s.actions, a) + return s +} + +func (s *Scene) Execute(ctx context.Context) error { + for _, a := range s.actions { + if err := a.Execute(ctx); err != nil { + return err + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + return nil +} diff --git a/scriptor.go b/scriptor.go new file mode 100644 index 0000000..53d83db --- /dev/null +++ b/scriptor.go @@ -0,0 +1,18 @@ +package scriptor + +import ( + "context" + "log/slog" + "os" + + "github.com/lestrrat-go/scriptor/log" + "github.com/lestrrat-go/scriptor/stash" +) + +func DefaultContext(ctx context.Context) context.Context { + newctx := stash.InjectContext( + log.InjectContext(ctx, slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))), + stash.New(), + ) + return newctx +} diff --git a/stash/stash.go b/stash/stash.go new file mode 100644 index 0000000..05a06a0 --- /dev/null +++ b/stash/stash.go @@ -0,0 +1,45 @@ +package stash + +import "context" + +type Stash interface { + Set(any, any) Stash + Get(any) (any, bool) +} + +type stashKey struct{} + +func FromContext(ctx context.Context) Stash { + v := ctx.Value(stashKey{}) + if v == nil { + return nil + } + if s, ok := v.(Stash); ok { + return s + } + return nil +} + +func InjectContext(ctx context.Context, s Stash) context.Context { + return context.WithValue(ctx, stashKey{}, s) +} + +type stash struct { + data map[any]any +} + +func New() Stash { + return &stash{ + data: make(map[any]any), + } +} + +func (s *stash) Set(k, v any) Stash { + s.data[k] = v + return s +} + +func (s *stash) Get(k any) (any, bool) { + v, ok := s.data[k] + return v, ok +}