diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go index 1e578b7..2faa8dd 100644 --- a/cmd/cli/check/check.go +++ b/cmd/cli/check/check.go @@ -52,10 +52,12 @@ var cmdPlanCheck = &cobra.Command{ planManifest, err := extractPlanManifest(loadedManifests) if err != nil { cli.Errorf("Failed to check plan manifest: %v", err) + return } if err := validatePlan(planManifest); err != nil { cli.Errorf("Failed to check plan: %v", err) + return } cli.Successf("Successfully checked plan manifest") diff --git a/go.mod b/go.mod index 22aea62..b7221f6 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/pterm/pterm v0.12.80 github.com/spf13/cobra v1.9.1 github.com/spf13/viper v1.20.1 + github.com/tidwall/gjson v1.18.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -76,6 +77,8 @@ require ( github.com/spf13/cast v1.7.1 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.etcd.io/bbolt v1.4.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect diff --git a/go.sum b/go.sum index a59dcee..e12e80b 100644 --- a/go.sum +++ b/go.sum @@ -202,6 +202,13 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= diff --git a/internal/core/manifests/interface.go b/internal/core/manifests/interface.go index 13ecf48..28bfa7b 100644 --- a/internal/core/manifests/interface.go +++ b/internal/core/manifests/interface.go @@ -14,7 +14,7 @@ const ( const ( PlanManifestKind = "Plan" - ValuesManifestLind = "Values" + ValuesManifestKind = "Values" ServerManifestKind = "Server" ServiceManifestKind = "Service" HttpTestManifestKind = "HttpTest" diff --git a/internal/core/manifests/kinds/plan/plan.go b/internal/core/manifests/kinds/plan/plan.go index f1eca86..883f0d8 100644 --- a/internal/core/manifests/kinds/plan/plan.go +++ b/internal/core/manifests/kinds/plan/plan.go @@ -4,6 +4,8 @@ import ( "fmt" "time" + "github.com/apiqube/cli/internal/core/runner/hooks" + "github.com/apiqube/cli/internal/core/manifests/utils" "github.com/google/uuid" @@ -35,19 +37,14 @@ type Stage struct { Parallel bool `yaml:"parallel,omitempty" json:"parallel,omitempty"` Params map[string]any `yaml:"params,omitempty" json:"params,omitempty" validate:"omitempty"` Mode string `yaml:"mode,omitempty" json:"mode,omitempty" validate:"omitempty,oneof=strict parallel"` // (strict|parallel) - Hooks Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty" validate:"omitempty,dive"` + Hooks *Hooks `yaml:"hooks,omitempty" json:"hooks,omitempty" validate:"omitempty,dive"` } type Hooks struct { - BeforeStart []Action `yaml:"beforeStart,omitempty" json:"beforeStart,omitempty" validate:"omitempty,dive"` - AfterFinish []Action `yaml:"afterFinish,omitempty" json:"afterFinish,omitempty" validate:"omitempty,dive"` - OnSuccess []Action `yaml:"onSuccess,omitempty" json:"onSuccess,omitempty" validate:"omitempty,dive"` - OnFailure []Action `yaml:"onFailure,omitempty" json:"onFailure,omitempty" validate:"omitempty,dive"` -} - -type Action struct { - Type string `yaml:"type" json:"type" validate:"required,oneof=log save skip fail exec notify"` // eg log/save/skip/fail/exec/notify - Params map[string]any `yaml:"params" json:"params" validate:"required"` + BeforeRun []hooks.Action `yaml:"beforeRun,omitempty" json:"beforeRun,omitempty" validate:"omitempty,dive"` + AfterRun []hooks.Action `yaml:"afterRun,omitempty" json:"afterRun,omitempty" validate:"omitempty,dive"` + OnSuccess []hooks.Action `yaml:"onSuccess,omitempty" json:"onSuccess,omitempty" validate:"omitempty,dive"` + OnFailure []hooks.Action `yaml:"onFailure,omitempty" json:"onFailure,omitempty" validate:"omitempty,dive"` } func (p *Plan) GetID() string { diff --git a/internal/core/manifests/kinds/servers/server.go b/internal/core/manifests/kinds/servers/server.go index 09e209c..969f9b8 100644 --- a/internal/core/manifests/kinds/servers/server.go +++ b/internal/core/manifests/kinds/servers/server.go @@ -19,7 +19,8 @@ type Server struct { kinds.BaseManifest `yaml:",inline" json:",inline" validate:"required"` Spec struct { - BaseUrl string `yaml:"baseUrl" json:"baseUrl" validate:"required,url"` + BaseURL string `yaml:"baseUrl" json:"baseUrl" validate:"required,url"` + Health string `yaml:"health" json:"health" validate:"omitempty,max=100"` Headers map[string]string `yaml:"headers,omitempty" json:"headers"` } `yaml:"spec" json:"spec" validate:"required"` diff --git a/internal/core/runner/accessor/accessor.go b/internal/core/runner/accessor/accessor.go new file mode 100644 index 0000000..541afb7 --- /dev/null +++ b/internal/core/runner/accessor/accessor.go @@ -0,0 +1,155 @@ +package accessor + +import ( + "fmt" + "strconv" + "strings" + + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +type DataAccessor interface { + Get(path string) (any, error) + GetString(path string) (string, error) + GetInt(path string) (int64, error) + GetFloat(path string) (float64, error) + GetBool(path string) (bool, error) + GetStringSlice(path string) ([]string, error) + GetMap(path string) (map[string]any, error) +} + +var _ DataAccessor = (*Accessor)(nil) + +type Accessor struct { + store interfaces.DataStore +} + +func NewAccessor(store interfaces.DataStore) *Accessor { + return &Accessor{store: store} +} + +func (a *Accessor) Get(path string) (any, error) { + key, subPath := splitKeyAndPath(path) + root, ok := a.store.Get(key) + if !ok { + return nil, fmt.Errorf("key not found: %s", key) + } + return walkPath(root, subPath) +} + +func (a *Accessor) GetString(path string) (string, error) { + v, err := a.Get(path) + if err != nil { + return "", err + } + + val, ok := v.(string) + if !ok { + return "", fmt.Errorf("not a string at path %s", path) + } + + return val, nil +} + +func (a *Accessor) GetInt(path string) (int64, error) { + v, err := a.Get(path) + if err != nil { + return -1, err + } + + val, ok := v.(int64) + if !ok { + return -1, fmt.Errorf("not a int at path %s", path) + } + + return val, nil +} + +func (a *Accessor) GetFloat(path string) (float64, error) { + v, err := a.Get(path) + if err != nil { + return -1, err + } + + val, ok := v.(float64) + if !ok { + return -1, fmt.Errorf("not a float at path %s", path) + } + + return val, nil +} + +func (a *Accessor) GetBool(path string) (bool, error) { + v, err := a.Get(path) + if err != nil { + return false, err + } + + val, ok := v.(bool) + if !ok { + return false, fmt.Errorf("not a bool at path %s", path) + } + + return val, nil +} + +func (a *Accessor) GetStringSlice(path string) ([]string, error) { + v, err := a.Get(path) + if err != nil { + return []string{}, err + } + + val, ok := v.([]string) + if !ok { + return []string{}, fmt.Errorf("not a string slice at path %s", path) + } + + return val, nil +} + +func (a *Accessor) GetMap(path string) (map[string]any, error) { + v, err := a.Get(path) + if err != nil { + return map[string]any{}, err + } + + val, ok := v.(map[string]any) + if !ok { + return map[string]any{}, fmt.Errorf("not a map at path %s", path) + } + + return val, nil +} + +func splitKeyAndPath(full string) (string, string) { + if idx := strings.LastIndex(full, "."); idx != -1 { + return full[:idx], full[idx+1:] + } + + return full, "" +} + +func walkPath(v any, path string) (any, error) { + if path == "" { + return v, nil + } + + parts := strings.Split(path, ".") + cur := v + + for _, part := range parts { + switch val := cur.(type) { + case map[string]any: + cur = val[part] + case []any: + idx, err := strconv.Atoi(part) + if err != nil || idx >= len(val) { + return nil, fmt.Errorf("invalid index: %s", part) + } + cur = val[idx] + default: + return nil, fmt.Errorf("unsupported or unexpected type at %s", part) + } + } + return cur, nil +} diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go new file mode 100644 index 0000000..7ae2021 --- /dev/null +++ b/internal/core/runner/assert/runner.go @@ -0,0 +1,37 @@ +package assert + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/tidwall/gjson" +) + +type Runner struct{} + +func NewRunner() *Runner { + return &Runner{} +} + +func (a *Runner) Assert(_ interfaces.ExecutionContext, assert *tests.Assert, _ *http.Response, raw []byte, _ any) error { + for _, el := range assert.Assertions { + val := gjson.GetBytes(raw, el.Target).Value() + + if el.Equals != nil && !reflect.DeepEqual(val, el.Equals) { + return fmt.Errorf("expected %v to equal %v", val, el.Equals) + } + if el.Contains != "" { + if s, ok := val.(string); !ok || !strings.Contains(s, el.Contains) { + return fmt.Errorf("expected %v to contain %q", val, el.Contains) + } + } + if el.Exists && val == nil { + return fmt.Errorf("expected %v to exist", el.Target) + } + } + return nil +} diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index ccb4b78..b746a94 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -24,19 +24,13 @@ func (o *Output) StartCase(manifest manifests.Manifest, caseName string) { func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *interfaces.CaseResult) { if result != nil { - cli.Println(fmt.Sprintf( - `Finish %s case from %s manifest with next reults - Result: %s - Success: %v - Status Code: %d - Duration: %s`, + cli.Infof("Finish %s case from %s manifest with next reults\nResult: %s\nSuccess: %v\nStatus Code: %d\nDuration: %s", caseName, manifest.GetName(), result.Name, result.Success, result.StatusCode, result.Duration.String(), - ), ) } else { cli.Infof("Finish %s case from %s manifest", caseName, manifest.GetName()) diff --git a/internal/core/runner/context/builder.go b/internal/core/runner/context/builder.go index b3dc18e..b087186 100644 --- a/internal/core/runner/context/builder.go +++ b/internal/core/runner/context/builder.go @@ -5,6 +5,8 @@ import ( "reflect" "sync" + "github.com/apiqube/cli/internal/core/runner/cli" + "github.com/apiqube/cli/internal/core/manifests" "github.com/apiqube/cli/internal/core/runner/interfaces" ) @@ -35,6 +37,7 @@ func NewCtxBuilder() *CtxBuilder { passChans: make(map[string]chan any), passKinds: make(map[string]reflect.Kind), passDone: make(map[string]bool), + output: cli.NewOutput(), } } diff --git a/internal/core/runner/context/context.go b/internal/core/runner/context/context.go index fbf7bac..7f3b141 100644 --- a/internal/core/runner/context/context.go +++ b/internal/core/runner/context/context.go @@ -48,7 +48,37 @@ func (c *ctxBaseImpl) Value(key any) any { return c.Context.Value(key) } -func (c *ctxBaseImpl) GetManifest(id string) (manifests.Manifest, error) { +func (c *ctxBaseImpl) GetAllManifests() []manifests.Manifest { + c.manifestsMutex.RLock() + defer c.manifestsMutex.RUnlock() + + ret := make([]manifests.Manifest, 0, len(c.manifests)) + for _, m := range c.manifests { + ret = append(ret, m) + } + + return ret +} + +func (c *ctxBaseImpl) GetManifestsByKind(kind string) ([]manifests.Manifest, error) { + c.manifestsMutex.RLock() + defer c.manifestsMutex.RUnlock() + + ret := make([]manifests.Manifest, 0, len(c.manifests)) + for _, m := range c.manifests { + if m.GetKind() == kind { + ret = append(ret, m) + } + } + + if len(ret) == 0 { + return nil, fmt.Errorf("no such manifest: %s", kind) + } + + return ret, nil +} + +func (c *ctxBaseImpl) GetManifestByID(id string) (manifests.Manifest, error) { c.manifestsMutex.RLock() defer c.manifestsMutex.RUnlock() @@ -64,8 +94,12 @@ func (c *ctxBaseImpl) Set(key string, value any) { func (c *ctxBaseImpl) Get(key string) (any, bool) { c.storeMutex.RLock() defer c.storeMutex.RUnlock() - v, ok := c.values[key] - return v, ok + + if v, ok := c.values[key]; ok { + return v, true + } + + return nil, false } func (c *ctxBaseImpl) Delete(key string) { @@ -90,12 +124,12 @@ func (c *ctxBaseImpl) SetTyped(key string, value any, kind reflect.Kind) { func (c *ctxBaseImpl) GetTyped(key string) (any, reflect.Kind, bool) { c.storeMutex.RLock() defer c.storeMutex.RUnlock() - v, ok := c.values[key] - if !ok { - return nil, reflect.Invalid, false + + if v, ok := c.values[key]; ok { + return v, c.kinds[key], true } - return v, c.kinds[key], true + return nil, reflect.Invalid, false } func (c *ctxBaseImpl) AsString(key string) (string, error) { diff --git a/internal/core/runner/executor/base_registry.go b/internal/core/runner/executor/base_registry.go new file mode 100644 index 0000000..2f1fa56 --- /dev/null +++ b/internal/core/runner/executor/base_registry.go @@ -0,0 +1,42 @@ +package executor + +import ( + "sync" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/executor/executors" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +var DefaultRegistry = &DefaultExecutorRegistry{ + executors: map[string]interfaces.Executor{ + manifests.ValuesManifestKind: executors.NewValuesExecutor(), + manifests.ServerManifestKind: executors.NewServerExecutor(), + }, +} + +var _ interfaces.ExecutorRegistry = (*DefaultExecutorRegistry)(nil) + +type DefaultExecutorRegistry struct { + sync.RWMutex + executors map[string]interfaces.Executor +} + +func NewDefaultExecutorRegistry() *DefaultExecutorRegistry { + return &DefaultExecutorRegistry{ + executors: make(map[string]interfaces.Executor), + } +} + +func (r *DefaultExecutorRegistry) Register(kind string, exec interfaces.Executor) { + r.Lock() + defer r.Unlock() + r.executors[kind] = exec +} + +func (r *DefaultExecutorRegistry) Find(kind string) (interfaces.Executor, bool) { + r.RLock() + defer r.RUnlock() + exec, ok := r.executors[kind] + return exec, ok +} diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go new file mode 100644 index 0000000..86d2fad --- /dev/null +++ b/internal/core/runner/executor/executors/http.go @@ -0,0 +1,162 @@ +package executors + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests/api" + "github.com/apiqube/cli/internal/core/runner/assert" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/apiqube/cli/internal/core/runner/pass" + "github.com/apiqube/cli/internal/core/runner/values" +) + +const httpExecutorOutputPrefix = "HTTP Executor:" + +var _ interfaces.Executor = (*HTTPExecutor)(nil) + +type HTTPExecutor struct { + client *http.Client + extractor *values.Extractor + assertor *assert.Runner + passer *pass.Runner +} + +func NewHTTPExecutor() *HTTPExecutor { + return &HTTPExecutor{ + client: &http.Client{Timeout: 30 * time.Second}, + extractor: values.NewExtractor(), + assertor: assert.NewRunner(), + passer: pass.NewRunner(), + } +} + +func (e *HTTPExecutor) Run(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { + _ = ctx.GetOutput() + var err error + + select { + case <-ctx.Done(): + return fmt.Errorf("%s run cancelled, run context was canceled", httpExecutorOutputPrefix) + default: + } + + httpMan, ok := manifest.(*api.Http) + if !ok { + return fmt.Errorf("%s manifest %s is not a %s kind", httpExecutorOutputPrefix, manifest.GetID(), manifests.HttpTestManifestKind) + } + + var wg sync.WaitGroup + errs := make(chan error, len(httpMan.Spec.Cases)) + + for _, c := range httpMan.Spec.Cases { + testCase := c + if testCase.Parallel { + wg.Add(1) + go func() { + defer wg.Done() + var caseErr error + if caseErr = e.runCase(ctx, httpMan, testCase); err != nil { + errs <- caseErr + } + }() + } else { + if err = e.runCase(ctx, httpMan, testCase); err != nil { + return err + } + } + } + + wg.Wait() + close(errs) + + var rErr error + if len(errs) > 0 { + for er := range errs { + rErr = errors.Join(rErr, er) + } + + return rErr + } + + return nil +} + +func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c api.HttpCase) error { + output := ctx.GetOutput() + url := c.Url + + if url == "" { + url = strings.TrimRight(man.Spec.Target, "/") + "/" + strings.TrimLeft(c.Endpoint, "/") + } + + url = e.passer.Apply(ctx, url, c.Pass) + headers := e.passer.MapHeaders(ctx, c.Headers, c.Pass) + body := e.passer.ApplyBody(ctx, c.Body, c.Pass) + + reqBody := &bytes.Buffer{} + + if body != nil { + if err := json.NewEncoder(reqBody).Encode(body); err != nil { + return fmt.Errorf("encode body failed: %w", err) + } + } + + req, err := http.NewRequest(c.Method, url, reqBody) + if err != nil { + return fmt.Errorf("create request failed: %w", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + timeout := c.Timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + client := &http.Client{Timeout: timeout} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("http request failed: %w", err) + } + + defer func() { + if err = resp.Body.Close(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s %s response body closed failed\nTarget: %s\nName: %s\nMathod: %s\nReason: %s", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target, c.Name, c.Method, err.Error()) + } + }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read response body failed: %w", err) + } + + var jsonBody any + if err = json.Unmarshal(respBody, &jsonBody); err != nil { + return fmt.Errorf("parse response body failed: %w", err) + } + + if c.Save != nil { + e.extractor.Extract(ctx, man.GetID(), c.HttpCase, resp, respBody, jsonBody) + } + + if c.Assert != nil { + if err = e.assertor.Assert(ctx, c.Assert, resp, respBody, jsonBody); err != nil { + return fmt.Errorf("assert failed: %w", err) + } + } + + output.Logf(interfaces.InfoLevel, "%s HTTP Test %s passed", httpExecutorOutputPrefix, c.Name) + + return nil +} diff --git a/internal/core/runner/executor/executors/server.go b/internal/core/runner/executor/executors/server.go new file mode 100644 index 0000000..a2c9da2 --- /dev/null +++ b/internal/core/runner/executor/executors/server.go @@ -0,0 +1,60 @@ +package executors + +import ( + "fmt" + "net/http" + "reflect" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/servers" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +const serverExecutorOutputPrefix = "Server Executor:" + +var _ interfaces.Executor = (*ServerExecutor)(nil) + +type ServerExecutor struct{} + +func NewServerExecutor() *ServerExecutor { + return &ServerExecutor{} +} + +func (e *ServerExecutor) Run(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { + output := ctx.GetOutput() + var err error + + select { + case <-ctx.Done(): + return fmt.Errorf("%s run cancelled, run context was canceled", serverExecutorOutputPrefix) + default: + } + + serverMan, ok := manifest.(*servers.Server) + if !ok { + return fmt.Errorf("%s manifest %s is not a %s kind", serverExecutorOutputPrefix, manifest.GetID(), manifests.ServerManifestKind) + } + + if serverMan.Spec.Health != "" { + var resp *http.Response + + resp, err = http.Get(serverMan.Spec.BaseURL + "/health") + if err != nil || resp.StatusCode >= 400 { + output.Logf(interfaces.WarnLevel, "%s server %s (%s) is not responding: %s", serverExecutorOutputPrefix, serverMan.GetName(), serverMan.Spec.BaseURL, err.Error()) + } + + output.Logf(interfaces.InfoLevel, "%s server %s (%s) is responding", serverExecutorOutputPrefix, serverMan.GetName(), serverMan.Spec.Health) + } + + baseKey := serverMan.GetID() + + ctx.SetTyped(fmt.Sprintf("%s.baseUrl", baseKey), serverMan.Spec.BaseURL, reflect.String) + + for key, val := range serverMan.Spec.Headers { + ctx.SetTyped(fmt.Sprintf("%s.headers.%s", baseKey, key), val, reflect.TypeOf(val).Kind()) + } + + output.Logf(interfaces.InfoLevel, "%s registered server: %s (%s)", serverExecutorOutputPrefix, serverMan.GetName(), serverMan.Spec.BaseURL) + + return nil +} diff --git a/internal/core/runner/executor/executors/values.go b/internal/core/runner/executor/executors/values.go new file mode 100644 index 0000000..34763bc --- /dev/null +++ b/internal/core/runner/executor/executors/values.go @@ -0,0 +1,43 @@ +package executors + +import ( + "fmt" + "reflect" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/values" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +const valuesExecutorOutputPrefix = "Values Executor:" + +var _ interfaces.Executor = (*ValuesExecutor)(nil) + +type ValuesExecutor struct{} + +func NewValuesExecutor() *ValuesExecutor { + return &ValuesExecutor{} +} + +func (e *ValuesExecutor) Run(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { + output := ctx.GetOutput() + + select { + case <-ctx.Done(): + return fmt.Errorf("%s run cancelled, run context was canceled", valuesExecutorOutputPrefix) + default: + } + + valueMan, ok := manifest.(*values.Values) + if !ok { + return fmt.Errorf("%s manifest %s is not a %s kind", valuesExecutorOutputPrefix, manifest.GetID(), manifests.ValuesManifestKind) + } + + for key, data := range valueMan.Spec.Data { + ctx.SetTyped(fmt.Sprintf("%s.%s", valueMan.GetID(), key), data, reflect.TypeOf(data).Kind()) + } + + output.Logf(interfaces.InfoLevel, "%s data from %s Values manifests successfully loaded to run context", valuesExecutorOutputPrefix, valueMan.GetName()) + + return nil +} diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go new file mode 100644 index 0000000..2da4886 --- /dev/null +++ b/internal/core/runner/executor/plan.go @@ -0,0 +1,188 @@ +package executor + +import ( + "errors" + "fmt" + "sync" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/runner/hooks" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +const planRunnerOutputPrefix = "Plan Runner:" + +var _ interfaces.PlanRunner = (*DefaultPlanRunner)(nil) + +type DefaultPlanRunner struct { + ExecutorRegistry interfaces.ExecutorRegistry + HooksRunner hooks.Runner +} + +func NewDefaultPlanRunner(registry interfaces.ExecutorRegistry, hooksRunner hooks.Runner) *DefaultPlanRunner { + return &DefaultPlanRunner{ + ExecutorRegistry: registry, + HooksRunner: hooksRunner, + } +} + +func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest manifests.Manifest) error { + p, ok := manifest.(*plan.Plan) + if !ok { + return errors.New("invalid manifest type, expected Plan kind") + } + + var err error + output := ctx.GetOutput() + + planID := p.GetID() + output.Logf(interfaces.InfoLevel, "%s starting plan: %s", planRunnerOutputPrefix, planID) + + if p.Spec.Hooks != nil { + if err = r.HooksRunner.RunHooks(ctx, hooks.BeforeRun, p.Spec.Hooks.BeforeRun); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan before start hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + return err + } + } + + for _, stage := range p.Spec.Stages { + stageName := stage.Name + output.Logf(interfaces.InfoLevel, "%s %s stage starting...", planRunnerOutputPrefix, stageName) + + if err = r.HooksRunner.RunHooks(ctx, hooks.BeforeRun, stage.Hooks.BeforeRun); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s before start hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) + return err + } + + var execErr error + if stage.Parallel { + execErr = r.runManifestsParallel(ctx, stage.Manifests) + } else { + execErr = r.runManifestsStrict(ctx, stage.Manifests) + } + + if err = r.HooksRunner.RunHooks(ctx, hooks.AfterRun, stage.Hooks.AfterRun); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s after finish hooks running failed: %s", planRunnerOutputPrefix, stageName, err.Error()) + return err + } + + if execErr != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s failed\nReason: %s", planRunnerOutputPrefix, stageName, execErr.Error()) + + if err = r.HooksRunner.RunHooks(ctx, hooks.OnFailure, stage.Hooks.OnFailure); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s on failure hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) + return err + } + + if p.Spec.Hooks != nil { + if err = r.HooksRunner.RunHooks(ctx, hooks.OnFailure, p.Spec.Hooks.OnFailure); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan on failure hooks runnin failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + return errors.Join(execErr, err) + } + } + + return execErr + } + + if err = r.HooksRunner.RunHooks(ctx, hooks.OnSuccess, stage.Hooks.OnSuccess); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan on success hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + return err + } + } + + if p.Spec.Hooks != nil { + if err = r.HooksRunner.RunHooks(ctx, hooks.AfterRun, p.Spec.Hooks.AfterRun); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan after finish hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + return err + } + + if err = r.HooksRunner.RunHooks(ctx, hooks.OnSuccess, p.Spec.Hooks.OnSuccess); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan on success hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) + return err + } + } + + output.Logf(interfaces.InfoLevel, "%s plan finished", planID) + + return nil +} + +func (r *DefaultPlanRunner) runManifestsStrict(ctx interfaces.ExecutionContext, manifestIDs []string) error { + var man manifests.Manifest + var err error + + output := ctx.GetOutput() + + for _, id := range manifestIDs { + if man, err = ctx.GetManifestByID(id); err != nil { + return fmt.Errorf("run %s manifest failed: %s", id, err.Error()) + } + + exec, exists := r.ExecutorRegistry.Find(man.GetKind()) + if !exists { + return fmt.Errorf("no executor found for kind: %s", man.GetKind()) + } + + output.Logf(interfaces.InfoLevel, "%s %s running manifest using executor for: %s", planRunnerOutputPrefix, id, man.GetKind()) + + if err = exec.Run(ctx, man); err != nil { + return fmt.Errorf("manifest %s failed: %s", id, err.Error()) + } + + output.Logf(interfaces.InfoLevel, "%s %s manifest finished", planRunnerOutputPrefix, id) + } + + return nil +} + +func (r *DefaultPlanRunner) runManifestsParallel(ctx interfaces.ExecutionContext, manifestIDs []string) error { + var wg sync.WaitGroup + errChan := make(chan error, len(manifestIDs)) + + output := ctx.GetOutput() + + for _, manId := range manifestIDs { + id := manId + wg.Add(1) + + go func() { + defer wg.Done() + man, err := ctx.GetManifestByID(id) + if err != nil { + errChan <- fmt.Errorf("run %s manifest failed: %s", id, err.Error()) + return + } + + exec, exists := r.ExecutorRegistry.Find(man.GetKind()) + if !exists { + errChan <- fmt.Errorf("no executor found for kind: %s", man.GetKind()) + return + } + + output.Logf(interfaces.InfoLevel, "%s %s running manifest using executor for: %s", planRunnerOutputPrefix, id, man.GetKind()) + + if err = exec.Run(ctx, man); err != nil { + errChan <- fmt.Errorf("manifest %s failed: %s", id, err.Error()) + return + } + + output.Logf(interfaces.InfoLevel, "%s %s manifest finished", planRunnerOutputPrefix, id) + }() + } + + wg.Wait() + close(errChan) + + var rErr error + + if len(errChan) > 0 { + for err := range errChan { + rErr = errors.Join(rErr, err) + } + + return rErr + } + + return nil +} diff --git a/internal/core/runner/hooks/hooks.go b/internal/core/runner/hooks/hooks.go new file mode 100644 index 0000000..2a8ec20 --- /dev/null +++ b/internal/core/runner/hooks/hooks.go @@ -0,0 +1,93 @@ +package hooks + +import ( + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +const hooksRunnerOutputPrefix = "Hooks Runner:" + +type Runner interface { + RunHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error + RegisterHooksHandler(event HookEvent, handler HookHandler) +} + +type HookEvent string + +func (h HookEvent) String() string { + return string(h) +} + +const ( + BeforeRun HookEvent = "before run" + AfterRun HookEvent = "after run" + OnSuccess HookEvent = "on success" + OnFailure HookEvent = "on failure" +) + +type HookHandler func(ctx interfaces.ExecutionContext, actions []Action) error + +type Action struct { + Type string `yaml:"type" json:"type" validate:"required,oneof=log save skip fail exec notify"` // eg log/save/skip/fail/exec/notify + Params map[string]any `yaml:"params" json:"params" validate:"required"` +} + +type DefaultHooksRunner struct { + entries map[HookEvent][]HookHandler +} + +func NewDefaultHooksRunner() *DefaultHooksRunner { + return &DefaultHooksRunner{ + entries: make(map[HookEvent][]HookHandler), + } +} + +func (r *DefaultHooksRunner) RunHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { + if len(actions) == 0 { + return nil + } + + switch event { + case BeforeRun: + return r.runBeforeRunHooks(ctx, event, actions) + case AfterRun: + return r.runAfterRunHooks(ctx, event, actions) + case OnSuccess: + return r.runOnSuccessHooks(ctx, event, actions) + case OnFailure: + return r.runOnFailureHooks(ctx, event, actions) + default: + return nil + } +} + +func (r *DefaultHooksRunner) RegisterHooksHandler(event HookEvent, handler HookHandler) { + r.entries[event] = append(r.entries[event], handler) +} + +func (r *DefaultHooksRunner) runBeforeRunHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { + output := ctx.GetOutput() + output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) + + return nil +} + +func (r *DefaultHooksRunner) runAfterRunHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { + output := ctx.GetOutput() + output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) + + return nil +} + +func (r *DefaultHooksRunner) runOnSuccessHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { + output := ctx.GetOutput() + output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) + + return nil +} + +func (r *DefaultHooksRunner) runOnFailureHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { + output := ctx.GetOutput() + output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) + + return nil +} diff --git a/internal/core/runner/interfaces/context.go b/internal/core/runner/interfaces/context.go index 663caaa..5458a6c 100644 --- a/internal/core/runner/interfaces/context.go +++ b/internal/core/runner/interfaces/context.go @@ -3,7 +3,7 @@ package interfaces import ( "context" - "github.com/apiqube/cli/internal/core/manifests/kinds/plan" + "github.com/apiqube/cli/internal/core/manifests" ) type ExecutorRegistry interface { @@ -12,11 +12,11 @@ type ExecutorRegistry interface { } type Executor interface { - Run(ctx ExecutionContext) error + Run(ctx ExecutionContext, manifest manifests.Manifest) error } type PlanRunner interface { - RunPlan(ctx ExecutionContext, plan *plan.Plan) error + RunPlan(ctx ExecutionContext, plan manifests.Manifest) error } type ExecutionContext interface { diff --git a/internal/core/runner/interfaces/hooks.go b/internal/core/runner/interfaces/hooks.go deleted file mode 100644 index 7e9ed03..0000000 --- a/internal/core/runner/interfaces/hooks.go +++ /dev/null @@ -1,19 +0,0 @@ -package interfaces - -type HookRunner interface { - RunHook(event HookEvent, ctx ExecutionContext, metadata map[string]any) error - RegisterHookHandler(event HookEvent, handler HookHandler) -} - -type HookEvent string - -const ( - beforeRun HookEvent = "beforeRun" - afterRun HookEvent = "afterRun" - BeforeStage HookEvent = "beforeStage" - AfterStage HookEvent = "afterStage" - OnSuccess HookEvent = "onSuccess" - OnFailure HookEvent = "onFailure" -) - -type HookHandler func(ctx ExecutionContext, metadata map[string]any) error diff --git a/internal/core/runner/interfaces/store.go b/internal/core/runner/interfaces/store.go index 5939617..758c56b 100644 --- a/internal/core/runner/interfaces/store.go +++ b/internal/core/runner/interfaces/store.go @@ -7,7 +7,9 @@ import ( ) type ManifestStore interface { - GetManifest(id string) (manifests.Manifest, error) + GetAllManifests() []manifests.Manifest + GetManifestsByKind(kind string) ([]manifests.Manifest, error) + GetManifestByID(id string) (manifests.Manifest, error) } type DataStore interface { @@ -18,6 +20,7 @@ type DataStore interface { SetTyped(key string, value any, kind reflect.Kind) GetTyped(key string) (any, reflect.Kind, bool) + AsString(key string) (string, error) AsInt(key string) (int64, error) AsFloat(key string) (float64, error) diff --git a/internal/core/runner/pass/runner.go b/internal/core/runner/pass/runner.go new file mode 100644 index 0000000..252a1c2 --- /dev/null +++ b/internal/core/runner/pass/runner.go @@ -0,0 +1,29 @@ +package pass + +import ( + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +type Runner struct{} + +func NewRunner() *Runner { + return &Runner{} +} + +func (p *Runner) Apply(ctx interfaces.ExecutionContext, input string, passes []tests.Pass) string { + // заменить плейсхолдеры в URL: {{.token}}, {{.user.id}}, и т.п. + // + обрабатывать Pass.Map + return "" + // return ReplaceWithStoreValues(ctx, input, passes) +} + +func (p *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, passes []tests.Pass) map[string]any { + // аналогично — пройтись по body и заменить шаблоны + return body +} + +func (p *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string]string, passes []tests.Pass) map[string]string { + // заменить плейсхолдеры в заголовках + return headers +} diff --git a/internal/core/runner/values/extractor.go b/internal/core/runner/values/extractor.go new file mode 100644 index 0000000..2afce84 --- /dev/null +++ b/internal/core/runner/values/extractor.go @@ -0,0 +1,58 @@ +package values + +import ( + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/tidwall/gjson" +) + +type Extractor struct{} + +func NewExtractor() *Extractor { + return &Extractor{} +} + +func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, c tests.HttpCase, resp *http.Response, raw []byte, _ any) { + group := c.Save.Group + + saveKey := func(key string) string { + var builder strings.Builder + + if group != "" { + builder.WriteString(fmt.Sprintf("Save.%s.%s", group, key)) + } else { + builder.WriteString(fmt.Sprintf("Save.%s.%s.%s", manifestID, c.Name, key)) + } + + return builder.String() + } + + // Json + for key, jsonPath := range c.Save.Json { + val := gjson.GetBytes(raw, jsonPath).Value() + ctx.SetTyped(saveKey(key), val, reflect.TypeOf(val).Kind()) + } + + // Headers + for key, headerName := range c.Save.Headers { + if val := resp.Header.Get(headerName); val != "" { + ctx.SetTyped(saveKey(fmt.Sprintf(".headers.%s", key)), val, reflect.String) + } + } + + if c.Save.All || c.Save.Status && c.Save.Body { + ctx.SetTyped(saveKey("status"), resp.StatusCode, reflect.Int) + ctx.Set(saveKey("body"), string(raw)) + } else { + if c.Save.Status { + ctx.SetTyped(saveKey("status"), resp.StatusCode, reflect.Int) + } else if c.Save.Body { + ctx.Set(saveKey("body"), string(raw)) + } + } +} diff --git a/internal/operations/parse.go b/internal/operations/parse.go index 017e0c7..2ea4a03 100644 --- a/internal/operations/parse.go +++ b/internal/operations/parse.go @@ -102,7 +102,7 @@ func Parse(format ParseFormat, data []byte) (manifests.Manifest, error) { switch raw.Kind { case manifests.PlanManifestKind: manifest = &plan.Plan{} - case manifests.ValuesManifestLind: + case manifests.ValuesManifestKind: manifest = &values.Values{} case manifests.ServerManifestKind: manifest = &servers.Server{}