diff --git a/Taskfile.yml b/Taskfile.yml index 03d8c48..914ce21 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,7 +3,7 @@ version: '3' vars: BINARY_NAME: qube BUILD_DIR: C:/Users/admin/go/bin - MAIN: ./cmd + MAIN: ./cmd/qube VERSION: sh: git describe --tags --abbrev=0 2>/dev/null || echo "dev" diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 9fa8d0f..fe015e9 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -6,6 +6,8 @@ import ( "os/signal" "syscall" + "github.com/apiqube/cli/cmd/cli/run" + "github.com/apiqube/cli/cmd/cli/apply" "github.com/apiqube/cli/cmd/cli/check" "github.com/apiqube/cli/cmd/cli/cleanup" @@ -40,6 +42,7 @@ var rootCmd = &cobra.Command{ func Execute() { rootCmd.AddCommand( versionCmd, + run.Cmd, apply.Cmd, check.Cmd, cleanup.Cmd, diff --git a/cmd/cli/run/run.go b/cmd/cli/run/run.go new file mode 100644 index 0000000..5a2f532 --- /dev/null +++ b/cmd/cli/run/run.go @@ -0,0 +1,209 @@ +package run + +import ( + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/io" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/context" + "github.com/apiqube/cli/internal/core/runner/executor" + "github.com/apiqube/cli/internal/core/runner/hooks" + runner "github.com/apiqube/cli/internal/core/runner/plan" + "github.com/apiqube/cli/internal/core/store" + "github.com/apiqube/cli/ui/cli" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "run", + Short: "Run tests by plan or generate it and run", + SilenceErrors: true, + SilenceUsage: true, + Run: func(cmd *cobra.Command, args []string) { + opts, err := parseOptions(cmd) + if err != nil { + cli.Errorf("Failed to parse provided values: %v", err) + return + } + + cli.Info("Loading manifests...") + loadedManifests, err := loadManifests(opts) + if err != nil { + cli.Errorf("Failed to load manifests: %v", err) + return + } + + cli.Infof("Loaded %d manifests", len(loadedManifests)) + cli.Info("Generating plan...") + + manager := runner.NewPlanManagerBuilder(). + WithManifests(loadedManifests...).Build() + + planManifest, err := manager.Generate() + if err != nil { + cli.Errorf("Failed to generate plan: %v", err) + return + } + + cli.Successf("Plan successfully generated") + + ctxBuilder := context.NewCtxBuilder(). + WithContext(cmd.Context()). + WithManifests(loadedManifests...) + + registry := executor.NewDefaultExecutorRegistry() + hooksRunner := hooks.NewDefaultHooksRunner() + + planRunner := executor.NewDefaultPlanRunner(registry, hooksRunner) + + runCtx := ctxBuilder.Build() + + if err = planRunner.RunPlan(runCtx, planManifest); err != nil { + return + } + + cli.Successf("Plan successfully runned") + }, +} + +func init() { + Cmd.Flags().StringArrayP("names", "n", []string{}, "Names of manifests to generate (comma separated)") + Cmd.Flags().StringP("namespace", "s", "", "Namespace of manifests to generate") + Cmd.Flags().StringArrayP("ids", "i", []string{}, "IDs of manifests to generate (comma separated)") + Cmd.Flags().StringArrayP("hashes", "H", []string{}, "Hash prefixes for manifests (min 5 chars each)") + + Cmd.Flags().StringP("file", "f", ".", "Path to manifest directory (default: current)") + + Cmd.Flags().BoolP("output", "o", false, "Make output after generating") + Cmd.Flags().String("output-path", "", "Output path to save the plan (default: current directory)") + Cmd.Flags().String("output-format", "yaml", "Output format (yaml|json)") +} + +type options struct { + names []string + namespace string + ids []string + hashes []string + + file string + + output bool + outputPath string + outputFormat string + + flagsSet map[string]bool +} + +func parseOptions(cmd *cobra.Command) (*options, error) { + opts := &options{ + flagsSet: make(map[string]bool), + } + + markFlag := func(name string) bool { + if cmd.Flags().Changed(name) { + opts.flagsSet[name] = true + return true + } + return false + } + + if markFlag("names") { + opts.names, _ = cmd.Flags().GetStringArray("names") + } + if markFlag("namespace") { + opts.namespace, _ = cmd.Flags().GetString("namespace") + } + if markFlag("ids") { + opts.ids, _ = cmd.Flags().GetStringArray("ids") + } + if markFlag("hashes") { + opts.hashes, _ = cmd.Flags().GetStringArray("hashes") + } + + if markFlag("file") { + opts.file, _ = cmd.Flags().GetString("file") + } + + if markFlag("output") { + opts.output, _ = cmd.Flags().GetBool("output") + } + if markFlag("output-path") { + opts.outputPath, _ = cmd.Flags().GetString("output-path") + } + if markFlag("output-format") { + opts.outputFormat, _ = cmd.Flags().GetString("output-format") + } + + exclusiveFlags := []string{"names", "namespace", "ids", "hashes", "file"} + + var usedFlags []string + for _, flag := range exclusiveFlags { + if opts.flagsSet[flag] { + usedFlags = append(usedFlags, "--"+flag) + } + } + + if len(usedFlags) > 1 { + return nil, fmt.Errorf( + "conflicting filters: %s\n"+ + "these filters cannot be used together, please use only one", + strings.Join(usedFlags, " and "), + ) + } + + if err := validateOptions(opts); err != nil { + return nil, err + } + + return opts, nil +} + +func validateOptions(opts *options) error { + if !opts.flagsSet["names"] && + !opts.flagsSet["namespace"] && + !opts.flagsSet["ids"] && + !opts.flagsSet["hashes"] && + !opts.flagsSet["file"] { + return fmt.Errorf("at least one generate filter must be specified") + } + return nil +} + +func loadManifests(opts *options) ([]manifests.Manifest, error) { + switch { + case opts.flagsSet["ids"]: + return store.Load(store.LoadOptions{ + IDs: opts.ids, + }) + + case opts.flagsSet["file"]: + loadedMans, cachedMans, err := io.LoadManifests(opts.file) + if err == nil { + cli.Infof("Manifests from provided path %s loaded", opts.file) + } + + loadedMans = append(loadedMans, cachedMans...) + return loadedMans, err + + default: + query := store.NewQuery() + if opts.flagsSet["names"] { + for _, name := range opts.names { + query.WithExactName(name) + } + } + + if opts.flagsSet["hashes"] { + for _, hash := range opts.hashes { + query.WithHashPrefix(hash) + } + } + + if opts.flagsSet["namespace"] { + query.WithNamespace(opts.namespace) + } + + return store.Search(query) + } +} diff --git a/examples/simple/server.yaml b/examples/server/server.yaml similarity index 100% rename from examples/simple/server.yaml rename to examples/server/server.yaml diff --git a/examples/simple-http-tests/http_test.yaml b/examples/simple-http-tests/http_test.yaml new file mode 100644 index 0000000..c06e57c --- /dev/null +++ b/examples/simple-http-tests/http_test.yaml @@ -0,0 +1,75 @@ +# API Test Configuration File +# -------------------------- +# version: Defines the schema version for future compatibility +version: v1 + +# kind: Specifies the test type (HttpTest for HTTP API testing) +kind: HttpTest + +# Metadata section - Contains identifying information +metadata: + # name: Unique identifier for this test suite + name: simple-test-example + + # namespace: Logical grouping for organizational purposes + namespace: simple-http-tests + +# spec: Main configuration container +spec: + # target: Base URL for all test cases (can be overridden per-case) + target: http://127.0.0.1:8081 + + # cases: List of test scenarios to execute + cases: + # Test Case 1: Basic GET request validation + - name: Get All Users Test # Descriptive test name + method: GET # HTTP method (GET/POST/PUT/etc) + endpoint: /users # Appended to target URL + + # assert: Validation rules + assert: + - target: status # What to validate (status code) + equals: 200 # Expected value (HTTP 200 OK) + + # Test Case 2: POST request with payload + - name: Create New User With Body + method: POST + endpoint: /users + + # headers: Request headers to include + headers: + Content-Type: application/json # Specifies JSON payload + + # body: Request payload (automatically JSON-encoded) + body: + name: "{{ Fake.name }}" # Generate fake name for request + email: "{{ Fake.email }}" # Generate fake email for request + age: "{{ Fake.uint.10.100 }}" # Generate fake positive number between 10 and 100 including + address: + street: "{{ Fake.email }}" + number: "{{ Fake.name }}" + assert: + - target: status + equals: 201 # HTTP 201 Created + + # Test Case 3: Getting and validating a user + - name: Get User By ID Test + method: GET + endpoint: /users/1 # Endpoint with user ID + assert: + - target: status + equals: 200 + + # Test Case 4: Absolute URL test + - name: Always Fail Endpoint Test + method: GET + url: http://127.0.0.1:8081/fail # Overrides spec.target + assert: + - target: status + equals: 500 # Expecting server error + + # Test Case 5: Performance testing + - name: Slow Endpoint Response Test + method: GET + endpoint: /slow?delay=2s # Test endpoint with artificial delay + timeout: 3s # Fail if response > 3 seconds \ No newline at end of file diff --git a/examples/simple/http_test.yaml b/examples/simple/http_test.yaml deleted file mode 100644 index a19b77d..0000000 --- a/examples/simple/http_test.yaml +++ /dev/null @@ -1,38 +0,0 @@ -version: v1 -kind: HttpTest -metadata: - name: simple-http-test -spec: - target: simple-server - cases: - - name: user-register - method: POST - endpoint: /register - headers: - type: some_data - Authorization: some_jwt_token - body: - username: "example_username" - email: "example_email" - password: "example_password" - expected: - code: 201 - message: "User successfully registered" - timeout: 1s - async: true - repeats: 20 - - - name: user-login - method: GET - endpoint: /login - body: - email: "example_email" - password: "example_password" - expected: - code: 200 - - name: user-fetch - method: GET - endpoint: /users/{id} - expected: - code: 201 - message: "User not found" diff --git a/examples/simple/values.yaml b/examples/values/values.yaml similarity index 100% rename from examples/simple/values.yaml rename to examples/values/values.yaml diff --git a/go.mod b/go.mod index b7221f6..780b7e5 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.3 require ( github.com/adrg/xdg v0.5.3 github.com/blevesearch/bleve/v2 v2.5.1 + github.com/brianvoe/gofakeit/v7 v7.2.1 github.com/charmbracelet/lipgloss v1.1.0 github.com/dgraph-io/badger/v4 v4.7.0 github.com/go-playground/validator/v10 v10.26.0 diff --git a/go.sum b/go.sum index e12e80b..2468d11 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +61,8 @@ github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFx github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= github.com/blevesearch/zapx/v16 v16.2.3 h1:7Y0r+a3diEvlazsncexq1qoFOcBd64xwMS7aDm4lo1s= github.com/blevesearch/zapx/v16 v16.2.3/go.mod h1:wVJ+GtURAaRG9KQAMNYyklq0egV+XJlGcXNCE0OFjjA= +github.com/brianvoe/gofakeit/v7 v7.2.1 h1:AGojgaaCdgq4Adzrd2uWdbGNDyX6MWNhHdQBraNfOHI= +github.com/brianvoe/gofakeit/v7 v7.2.1/go.mod h1:QXuPeBw164PJCzCUZVmgpgHJ3Llj49jSLVkKPMtxtxA= 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/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= diff --git a/internal/core/manifests/kinds/tests/api/http.go b/internal/core/manifests/kinds/tests/api/http.go index 9fa6095..1da1492 100644 --- a/internal/core/manifests/kinds/tests/api/http.go +++ b/internal/core/manifests/kinds/tests/api/http.go @@ -22,7 +22,7 @@ type Http struct { Spec struct { Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required"` - Cases []HttpCase `yaml:"cases" json:"cases" valid:"required,min=1,max=100,dive"` + Cases []HttpCase `yaml:"cases" json:"cases" validate:"required,min=1,max=100,dive"` } `yaml:"spec" json:"spec" validate:"required"` kinds.Dependencies `yaml:",inline" json:",inline" validate:"omitempty"` diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index e55532f..c74e4fd 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -11,20 +11,17 @@ type HttpCase struct { Url string `yaml:"url,omitempty" json:"url,omitempty" validate:"omitempty,url"` Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty,min=1,max=100"` Body map[string]any `yaml:"body,omitempty" json:"body,omitempty" validate:"omitempty,min=1,max=100"` - Assert *Assert `yaml:"assert,omitempty" json:"assert,omitempty" validate:"omitempty"` + Assert []*Assert `yaml:"assert,omitempty" json:"assert,omitempty" validate:"omitempty,min=1,max=50,dive"` Save *Save `yaml:"save,omitempty" json:"save,omitempty" validate:"omitempty"` Pass []Pass `yaml:"pass,omitempty" json:"pass,omitempty" validate:"omitempty,min=1,max=25,dive"` Timeout time.Duration `yaml:"timeout,omitempty" json:"timeout,omitempty" validate:"omitempty,duration"` Parallel bool `yaml:"async,omitempty" json:"async,omitempty" validate:"omitempty,boolean"` + Details []string `yaml:"details,omitempty" json:"details,omitempty" validate:"omitempty,min=1,max=100"` } type Assert struct { - Assertions []AssertElement `yaml:",inline,omitempty" json:",inline,omitempty" validate:"required,min=1,max=50,dive"` -} - -type AssertElement struct { - Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,min=3,max=128"` - Equals any `yaml:"equals,omitempty" json:"equals,omitempty"` + Target string `yaml:"target,omitempty" json:"target,omitempty" validate:"required,oneof=status body headers"` + Equals any `yaml:"equals,omitempty" json:"equals,omitempty" validate:"omitempty,min=1,max=100"` Contains string `yaml:"contains,omitempty" json:"contains,omitempty" validate:"omitempty,min=1,max=100"` Exists bool `yaml:"exists,omitempty" json:"exists,omitempty" validate:"omitempty,boolean"` Template string `yaml:"template,omitempty" json:"template,omitempty" validate:"omitempty,min=1,max=100,contains_template"` diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 7ae2021..f7bc2c4 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -1,37 +1,166 @@ package assert import ( + "bytes" + "encoding/json" "fmt" "net/http" "reflect" "strings" + "github.com/apiqube/cli/internal/core/runner/templates" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" "github.com/apiqube/cli/internal/core/runner/interfaces" - "github.com/tidwall/gjson" ) -type Runner struct{} +type Type string + +const ( + Status Type = "status" + Body Type = "body" + Headers Type = "headers" +) + +func (t Type) String() string { + return string(t) +} + +type Runner struct { + templateEngine *templates.TemplateEngine +} func NewRunner() *Runner { - return &Runner{} + return &Runner{ + templateEngine: templates.New(), + } +} + +func (r *Runner) Assert(ctx interfaces.ExecutionContext, asserts []*tests.Assert, resp *http.Response, body []byte) error { + for _, assert := range asserts { + switch assert.Target { + case Status.String(): + return r.assertStatus(ctx, assert, resp) + case Body.String(): + return r.assertBody(ctx, assert, resp, body) + case Headers.String(): + return r.assertHeaders(ctx, assert, resp) + default: + return fmt.Errorf("assert failed: unknown assert target %s", assert.Target) + } + } + return nil +} + +func (r *Runner) assertStatus(_ interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { + if assert.Equals != nil { + expectedCode, ok := assert.Equals.(int) + if !ok { + return fmt.Errorf("assertion failed, expected status correct value got %v", assert.Equals) + } + + if resp.StatusCode != expectedCode { + return fmt.Errorf("assertion failed, expected status code %v, got %v", expectedCode, resp.StatusCode) + } + } + + if assert.Contains != "" { + if !strings.Contains(resp.Status, assert.Contains) { + return fmt.Errorf("assertion failed, expected %v to contain %q", resp.Status, assert.Contains) + } + } + + return nil } -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() +func (r *Runner) assertBody(_ interfaces.ExecutionContext, assert *tests.Assert, _ *http.Response, body []byte) error { + if assert.Exists { + if len(body) == 0 { + return fmt.Errorf("assertion failed, expected not null body") + } + + return nil + } + + if assert.Template != "" { + tplResult, err := r.templateEngine.Execute(assert.Template) + if err != nil { + return fmt.Errorf("template execution error: %v", err) + } + + expected, err := json.Marshal(tplResult) + if err != nil { + return fmt.Errorf("template marshal error: %v", err) + } + + if !reflect.DeepEqual(body, expected) { + return fmt.Errorf("body doesn't match template\nexpected: %s\nactual: %s", expected, body) + } + + return nil + } + + if assert.Equals != nil { + var expected any + if err := json.Unmarshal([]byte(fmt.Sprintf("%v", assert.Equals)), &expected); err != nil { + return fmt.Errorf("invalid Equals in body target value: %v", err) + } + + if !reflect.DeepEqual(body, expected) { + return fmt.Errorf("assert failed, expected body %v to equal %v", string(body), expected) + } + return nil + } + + if assert.Contains != "" { + if !bytes.Contains(body, []byte(assert.Contains)) { + return fmt.Errorf("assertion failed, expected %v in body", assert.Contains) + } - if el.Equals != nil && !reflect.DeepEqual(val, el.Equals) { - return fmt.Errorf("expected %v to equal %v", val, el.Equals) + return nil + } + + return nil +} + +func (r *Runner) assertHeaders(_ interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { + if assert.Equals != nil { + if equals, ok := assert.Equals.(map[string]any); ok { + return fmt.Errorf("assertion failed, expected map assertion got %v", assert.Equals) + } else { + for key, expectedVal := range equals { + actualVal := resp.Header.Get(key) + if fmt.Sprintf("%v", expectedVal) != actualVal { + return fmt.Errorf("assertion failed, expected header value %v, got %v", expectedVal, actualVal) + } + } } - 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 assert.Contains != "" { + found := false + for _, values := range resp.Header { + for _, val := range values { + if strings.Contains(val, assert.Contains) { + found = true + break + } + } + if found { + break } } - if el.Exists && val == nil { - return fmt.Errorf("expected %v to exist", el.Target) + + if !found { + return fmt.Errorf("assertion failed, expected header %v but not found", assert.Contains) } } + + if assert.Exists { + if len(resp.Header) == 0 { + return fmt.Errorf("assertion failed, expected some headers in response") + } + } + return nil } diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index b746a94..9a21b1f 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -2,8 +2,11 @@ package cli import ( "fmt" + "sort" "strings" + "github.com/apiqube/cli/ui" + "github.com/apiqube/cli/ui/cli" "github.com/apiqube/cli/internal/core/manifests" @@ -19,21 +22,97 @@ func NewOutput() *Output { } func (o *Output) StartCase(manifest manifests.Manifest, caseName string) { - cli.Infof("Start %s case from %s manifest", caseName, manifest.GetName()) + cli.LogStyledf(ui.TypeInfo, + "Start [%s] case from [%s] manifest", + cli.LogPair{Message: caseName, Style: &cli.InfoStyle}, + cli.LogPair{Message: manifest.GetName(), Style: &cli.WarningStyle}, + ) } func (o *Output) EndCase(manifest manifests.Manifest, caseName string, result *interfaces.CaseResult) { if result != nil { - 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(), - ) + successStyle := cli.SuccessStyle + successText := "yes" + if !result.Success { + successStyle = cli.ErrorStyle + successText = "no" + } + + assertStyle := cli.SuccessStyle + var assertText string + if result.Assert != "" { + if result.Assert == "no" { + assertText = result.Assert + assertStyle = cli.ErrorStyle + } else { + assertText = result.Assert + } + } + + errorsFormatted := "" + if len(result.Errors) > 0 { + var errorsBuilder strings.Builder + for _, err := range result.Errors { + errorsBuilder.WriteString(fmt.Sprintf("\n- %s", err)) + } + errorsFormatted = fmt.Sprintf("\nErrors: %s", errorsBuilder.String()) + } + + detailsFormatted := "" + if len(result.Details) > 0 { + var detailsBuilder strings.Builder + var keys []string + + for key := range result.Details { + keys = append(keys, key) + } + + sort.Strings(keys) + + for _, key := range keys { + detailsBuilder.WriteString(fmt.Sprintf("\n- %s: %v", key, result.Details[key])) + } + + detailsFormatted = fmt.Sprintf("\nDetails: %s", detailsBuilder.String()) + } + + var builder strings.Builder + + builder.WriteString(fmt.Sprintf("Finish [%s] case from [%s] manifest with next results", + cli.LogPair{Message: caseName, Style: &cli.InfoStyle}.String(), + cli.LogPair{Message: manifest.GetName(), Style: &cli.WarningStyle}, + )) + + builder.WriteString(fmt.Sprintf("\nResult: %s", cli.LogPair{Message: result.Name}.String())) + builder.WriteString(fmt.Sprintf("\nSuccess: %s", cli.LogPair{Message: successText, Style: &successStyle}.String())) + + if assertText != "" { + builder.WriteString(fmt.Sprintf("\nAssert: %s", cli.LogPair{Message: assertText, Style: &assertStyle}.String())) + } + + if result.StatusCode != 0 { + builder.WriteString(fmt.Sprintf("\nStatus Code: %s", cli.LogPair{Message: fmt.Sprint(result.StatusCode)}.String())) + } + + if result.Duration != 0 { + builder.WriteString(fmt.Sprintf("\nDuration: %s", cli.LogPair{Message: result.Duration.String()}.String())) + } + + if len(errorsFormatted) > 0 { + builder.WriteString(cli.LogPair{Message: errorsFormatted, Style: &cli.ErrorStyle}.String()) + } + + if len(detailsFormatted) > 0 { + builder.WriteString(cli.LogPair{Message: detailsFormatted, Style: &cli.TimestampStyle}.String()) + } + + cli.Info(builder.String()) } else { - cli.Infof("Finish %s case from %s manifest", caseName, manifest.GetName()) + cli.LogStyledf(ui.TypeInfo, + "Finish [%s] case from [%s] manifest with next results", + cli.LogPair{Message: caseName, Style: &cli.InfoStyle}, + cli.LogPair{Message: manifest.GetName(), Style: &cli.WarningStyle}, + ) } } @@ -51,6 +130,8 @@ func (o *Output) Log(level interfaces.LogLevel, msg string) { cli.Warning(msg) case interfaces.ErrorLevel: cli.Error(msg) + case interfaces.FatalLevel: + cli.Fatal(msg) default: cli.Info(msg) } @@ -66,6 +147,8 @@ func (o *Output) Logf(level interfaces.LogLevel, format string, args ...any) { cli.Warningf(format, args...) case interfaces.ErrorLevel: cli.Errorf(format, args...) + case interfaces.FatalLevel: + cli.Fatalf(format, args...) default: cli.Infof(format, args...) } diff --git a/internal/core/runner/executor/base_registry.go b/internal/core/runner/executor/base_registry.go index 2f1fa56..d0927bf 100644 --- a/internal/core/runner/executor/base_registry.go +++ b/internal/core/runner/executor/base_registry.go @@ -10,8 +10,9 @@ import ( var DefaultRegistry = &DefaultExecutorRegistry{ executors: map[string]interfaces.Executor{ - manifests.ValuesManifestKind: executors.NewValuesExecutor(), - manifests.ServerManifestKind: executors.NewServerExecutor(), + manifests.ValuesManifestKind: executors.NewValuesExecutor(), + manifests.ServerManifestKind: executors.NewServerExecutor(), + manifests.HttpTestManifestKind: executors.NewHTTPExecutor(), }, } @@ -23,9 +24,7 @@ type DefaultExecutorRegistry struct { } func NewDefaultExecutorRegistry() *DefaultExecutorRegistry { - return &DefaultExecutorRegistry{ - executors: make(map[string]interfaces.Executor), - } + return DefaultRegistry } func (r *DefaultExecutorRegistry) Register(kind string, exec interfaces.Executor) { diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index 86d2fad..6337e9c 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -2,6 +2,7 @@ package executors import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -11,11 +12,13 @@ import ( "sync" "time" + "github.com/apiqube/cli/internal/core/runner/metrics" + "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/form" "github.com/apiqube/cli/internal/core/runner/interfaces" - "github.com/apiqube/cli/internal/core/runner/pass" "github.com/apiqube/cli/internal/core/runner/values" ) @@ -27,7 +30,7 @@ type HTTPExecutor struct { client *http.Client extractor *values.Extractor assertor *assert.Runner - passer *pass.Runner + passer *form.Runner } func NewHTTPExecutor() *HTTPExecutor { @@ -35,7 +38,7 @@ func NewHTTPExecutor() *HTTPExecutor { client: &http.Client{Timeout: 30 * time.Second}, extractor: values.NewExtractor(), assertor: assert.NewRunner(), - passer: pass.NewRunner(), + passer: form.NewRunner(), } } @@ -92,10 +95,42 @@ func (e *HTTPExecutor) Run(ctx interfaces.ExecutionContext, manifest manifests.M func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c api.HttpCase) error { output := ctx.GetOutput() + + start := time.Now() + caseResult := &interfaces.CaseResult{ + Name: c.Name, + Values: make(map[string]any), + Details: make(map[string]any), + } + + var ( + req *http.Request + resp *http.Response + err error + ) + + output.StartCase(man, c.Name) + + defer func() { + metrics.CollectHTTPMetrics(req, resp, c.Details, caseResult) + + caseResult.Duration = time.Since(start) + output.EndCase(man, c.Name, caseResult) + }() + url := c.Url if url == "" { - url = strings.TrimRight(man.Spec.Target, "/") + "/" + strings.TrimLeft(c.Endpoint, "/") + baseUrl := strings.TrimRight(man.Spec.Target, "/") + endpoint := strings.TrimLeft(c.Endpoint, "/") + + if baseUrl != "" && endpoint != "" { + url = baseUrl + "/" + endpoint + } else if baseUrl != "" { + url = baseUrl + } else { + url = endpoint + } } url = e.passer.Apply(ctx, url, c.Pass) @@ -106,12 +141,14 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c if body != nil { if err := json.NewEncoder(reqBody).Encode(body); err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to encode request body: %s", err.Error())) return fmt.Errorf("encode body failed: %w", err) } } - req, err := http.NewRequest(c.Method, url, reqBody) + req, err = http.NewRequest(c.Method, url, reqBody) if err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to create request: %s", err.Error())) return fmt.Errorf("create request failed: %w", err) } @@ -121,41 +158,53 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c timeout := c.Timeout if timeout == 0 { - timeout = 30 * time.Second + timeout = 5 * time.Second } client := &http.Client{Timeout: timeout} - resp, err := client.Do(req) + resp, err = client.Do(req) if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + caseResult.Errors = append(caseResult.Errors, "request timed out") + return fmt.Errorf("request to %s timed out", url) + } + return fmt.Errorf("http request failed: %w", err) } defer func() { if err = resp.Body.Close(); err != nil { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to close response body: %s", err.Error())) 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 { + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("failed to read response body: %s", err.Error())) 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) + output.Logf(interfaces.InfoLevel, "%s data extraction for %s %s ", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target) + + e.extractor.Extract(ctx, man.GetID(), c.HttpCase, resp, respBody) } if c.Assert != nil { - if err = e.assertor.Assert(ctx, c.Assert, resp, respBody, jsonBody); err != nil { + output.Logf(interfaces.InfoLevel, "%s reponse asserting for %s %s ", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target) + + if err = e.assertor.Assert(ctx, c.Assert, resp, respBody); err != nil { + caseResult.Assert = "no" + caseResult.Errors = append(caseResult.Errors, fmt.Sprintf("assertion failed: %s", err.Error())) return fmt.Errorf("assert failed: %w", err) } + + caseResult.Assert = "yes" } + caseResult.StatusCode = resp.StatusCode + caseResult.Success = true output.Logf(interfaces.InfoLevel, "%s HTTP Test %s passed", httpExecutorOutputPrefix, c.Name) return nil diff --git a/internal/core/runner/executor/plan.go b/internal/core/runner/executor/plan.go index 2da4886..958c524 100644 --- a/internal/core/runner/executor/plan.go +++ b/internal/core/runner/executor/plan.go @@ -16,14 +16,14 @@ const planRunnerOutputPrefix = "Plan Runner:" var _ interfaces.PlanRunner = (*DefaultPlanRunner)(nil) type DefaultPlanRunner struct { - ExecutorRegistry interfaces.ExecutorRegistry - HooksRunner hooks.Runner + registry interfaces.ExecutorRegistry + hooksRunner hooks.Runner } func NewDefaultPlanRunner(registry interfaces.ExecutorRegistry, hooksRunner hooks.Runner) *DefaultPlanRunner { return &DefaultPlanRunner{ - ExecutorRegistry: registry, - HooksRunner: hooksRunner, + registry: registry, + hooksRunner: hooksRunner, } } @@ -39,20 +39,32 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma planID := p.GetID() output.Logf(interfaces.InfoLevel, "%s starting plan: %s", planRunnerOutputPrefix, planID) + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled before start: %v", planRunnerOutputPrefix, err) + return err + } + if p.Spec.Hooks != nil { - if err = r.HooksRunner.RunHooks(ctx, hooks.BeforeRun, p.Spec.Hooks.BeforeRun); err != nil { + if err = r.runHooksWithContext(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 { + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled before stage %s: %v", planRunnerOutputPrefix, stage.Name, err) + return err + } + 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 + if stage.Hooks != nil { + if err = r.runHooksWithContext(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 @@ -62,22 +74,31 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma 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()) + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled after stage %s: %v", planRunnerOutputPrefix, stage.Name, err) return err } + if stage.Hooks != nil { + if err = r.runHooksWithContext(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 stage.Hooks != nil { + if err = r.runHooksWithContext(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()) + if err = r.runHooksWithContext(ctx, hooks.OnFailure, p.Spec.Hooks.OnFailure); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan on failure hooks running failed\nReason: %s", planRunnerOutputPrefix, err.Error()) return errors.Join(execErr, err) } } @@ -85,19 +106,26 @@ func (r *DefaultPlanRunner) RunPlan(ctx interfaces.ExecutionContext, manifest ma 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 stage.Hooks != nil { + if err = r.runHooksWithContext(ctx, hooks.OnSuccess, stage.Hooks.OnSuccess); err != nil { + output.Logf(interfaces.ErrorLevel, "%s stage %s on success hooks running failed\nReason: %s", planRunnerOutputPrefix, stageName, err.Error()) + return err + } } } + if err = ctx.Err(); err != nil { + output.Logf(interfaces.ErrorLevel, "%s plan execution canceled before final hooks: %v", planRunnerOutputPrefix, err) + return err + } + if p.Spec.Hooks != nil { - if err = r.HooksRunner.RunHooks(ctx, hooks.AfterRun, p.Spec.Hooks.AfterRun); err != nil { + if err = r.runHooksWithContext(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 { + if err = r.runHooksWithContext(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 } @@ -119,12 +147,12 @@ func (r *DefaultPlanRunner) runManifestsStrict(ctx interfaces.ExecutionContext, return fmt.Errorf("run %s manifest failed: %s", id, err.Error()) } - exec, exists := r.ExecutorRegistry.Find(man.GetKind()) + exec, exists := r.registry.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()) + output.Logf(interfaces.InfoLevel, "%s running %s manifest using %s executor", planRunnerOutputPrefix, id, man.GetKind()) if err = exec.Run(ctx, man); err != nil { return fmt.Errorf("manifest %s failed: %s", id, err.Error()) @@ -154,13 +182,13 @@ func (r *DefaultPlanRunner) runManifestsParallel(ctx interfaces.ExecutionContext return } - exec, exists := r.ExecutorRegistry.Find(man.GetKind()) + exec, exists := r.registry.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()) + output.Logf(interfaces.InfoLevel, "%s running %s manifest using %s executor", planRunnerOutputPrefix, id, man.GetKind()) if err = exec.Run(ctx, man); err != nil { errChan <- fmt.Errorf("manifest %s failed: %s", id, err.Error()) @@ -186,3 +214,16 @@ func (r *DefaultPlanRunner) runManifestsParallel(ctx interfaces.ExecutionContext return nil } + +func (r *DefaultPlanRunner) runHooksWithContext(ctx interfaces.ExecutionContext, event hooks.HookEvent, actions []hooks.Action) error { + if len(actions) == 0 { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + return r.hooksRunner.RunHooks(ctx, event, actions) + } +} diff --git a/internal/core/runner/form/runner.go b/internal/core/runner/form/runner.go new file mode 100644 index 0000000..55937eb --- /dev/null +++ b/internal/core/runner/form/runner.go @@ -0,0 +1,132 @@ +package form + +import ( + "fmt" + "regexp" + "strings" + + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/apiqube/cli/internal/core/runner/templates" +) + +type Runner struct { + fakeEngine *templates.TemplateEngine +} + +func NewRunner() *Runner { + return &Runner{ + fakeEngine: templates.New(), + } +} + +func (r *Runner) Apply(ctx interfaces.ExecutionContext, input string, pass []tests.Pass) string { + result := input + for _, p := range pass { + if p.Map != nil { + for placeholder, mapKey := range p.Map { + if strings.Contains(result, placeholder) { + if val, ok := ctx.Get(mapKey); ok { + result = strings.ReplaceAll(result, placeholder, fmt.Sprintf("%v", val)) + } + } + } + } + } + + re := regexp.MustCompile(`\{\{\s*([^}\s]+)\s*}}`) + result = re.ReplaceAllStringFunc(result, func(match string) string { + key := strings.Trim(match, "{} \t") + + if val, ok := ctx.Get(key); ok { + return fmt.Sprintf("%v", val) + } + + if strings.HasPrefix(key, "Fake.") { + if val, err := r.fakeEngine.Execute(match); err == nil { + return fmt.Sprintf("%v", val) + } + } + + return match + }) + + return result +} + +func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, pass []tests.Pass) map[string]any { + if body == nil { + return nil + } + + result := make(map[string]any, len(body)) + + for key, value := range body { + switch v := value.(type) { + case string: + result[key] = r.renderTemplate(ctx, v) + case map[string]any: + result[key] = r.ApplyBody(ctx, v, pass) + case []any: + result[key] = r.applyArray(ctx, v, pass) + default: + result[key] = v + } + } + + return result +} + +func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string]string, pass []tests.Pass) map[string]string { + if headers == nil { + return nil + } + + result := make(map[string]string, len(headers)) + + for key, value := range headers { + processedKey := r.Apply(ctx, key, pass) + processedValue := r.Apply(ctx, value, pass) + result[processedKey] = processedValue + } + + return result +} + +func (r *Runner) renderTemplate(_ interfaces.ExecutionContext, raw string) any { + if !strings.Contains(raw, "{{") { + return raw + } + + if strings.Contains(raw, "Fake") { + result, err := r.fakeEngine.Execute(raw) + if err != nil { + return raw + } + + return result + } + + return raw +} + +func (r *Runner) applyArray(ctx interfaces.ExecutionContext, arr []any, pass []tests.Pass) []any { + if arr == nil { + return nil + } + + result := make([]any, len(arr)) + for i, item := range arr { + switch v := item.(type) { + case string: + result[i] = r.renderTemplate(ctx, v) + case map[string]any: + result[i] = r.ApplyBody(ctx, v, pass) + case []any: + result[i] = r.applyArray(ctx, v, pass) + default: + result[i] = v + } + } + return result +} diff --git a/internal/core/runner/hooks/hooks.go b/internal/core/runner/hooks/hooks.go index 2a8ec20..8cb9078 100644 --- a/internal/core/runner/hooks/hooks.go +++ b/internal/core/runner/hooks/hooks.go @@ -64,28 +64,28 @@ func (r *DefaultHooksRunner) RegisterHooksHandler(event HookEvent, handler HookH r.entries[event] = append(r.entries[event], handler) } -func (r *DefaultHooksRunner) runBeforeRunHooks(ctx interfaces.ExecutionContext, event HookEvent, actions []Action) error { +func (r *DefaultHooksRunner) runBeforeRunHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []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 { +func (r *DefaultHooksRunner) runAfterRunHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []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 { +func (r *DefaultHooksRunner) runOnSuccessHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []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 { +func (r *DefaultHooksRunner) runOnFailureHooks(ctx interfaces.ExecutionContext, event HookEvent, _ []Action) error { output := ctx.GetOutput() output.Logf(interfaces.InfoLevel, "%s running %s hooks", hooksRunnerOutputPrefix, event.String()) diff --git a/internal/core/runner/interfaces/output.go b/internal/core/runner/interfaces/output.go index 15d3668..01e01a8 100644 --- a/internal/core/runner/interfaces/output.go +++ b/internal/core/runner/interfaces/output.go @@ -13,23 +13,3 @@ type Output interface { DumpValues(values map[string]any) Error(err error) } - -type Message struct { - Format string - Values []any -} - -type Progress struct { - Stage string - Step string - Total int - Done int -} - -type MetricResult struct { - Name string - Value float64 - Unit string - Warn float64 - Error float64 -} diff --git a/internal/core/runner/interfaces/result.go b/internal/core/runner/interfaces/result.go index ecd375d..c57940b 100644 --- a/internal/core/runner/interfaces/result.go +++ b/internal/core/runner/interfaces/result.go @@ -7,9 +7,30 @@ import ( type CaseResult struct { Name string Success bool + Assert string StatusCode int Duration time.Duration Errors []string Values map[string]any Details map[string]any } + +type Message struct { + Format string + Values []any +} + +type Progress struct { + Stage string + Step string + Total int + Done int +} + +type MetricResult struct { + Name string + Value float64 + Unit string + Warn float64 + Error float64 +} diff --git a/internal/core/runner/metrics/http.go b/internal/core/runner/metrics/http.go new file mode 100644 index 0000000..1727358 --- /dev/null +++ b/internal/core/runner/metrics/http.go @@ -0,0 +1,253 @@ +package metrics + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// Request +const ( + RequestGeneral = "Request-General" + RequestHeaders = "Request-Headers" + RequestTiming = "Request-Timing" + + RequestContentLength = "Request-Content-Length" + RequestContentType = "Request-Content-Type" + RequestHost = "Request-Host" + RequestMethod = "Request-Method" + RequestPost = "Request-Post" + RequestProtocol = "Request-Protocol" + RequestQuery = "Request-Query" + RequestRemoteAddr = "Request-Remote-Addr" + RequestURI = "Request-URI" + RequestAuthorization = "Request-Authorization" + RequestCookies = "Request-Cookies" + RequestReferer = "Request-Referer" + RequestUserAgent = "Request-User-Agent" + RequestXForwardedFor = "Request-X-Forwarded-For" + RequestScheme = "Request-Scheme" + RequestPath = "Request-Path" + RequestTime = "Request-Time" +) + +// Response +const ( + ResponseGeneral = "Response-General" + ResponseHeaders = "Response-Headers" + ResponseTiming = "Response-Timing" + + ResponseContentLength = "Response-Content-Length" + ResponseProtocol = "Response-Protocol" + ResponseStatus = "Response-Status" + ResponseStatusCode = "Response-StatusCode" + ResponseContentType = "Response-ContentType" + ResponseTime = "Response-Time" + ResponseLatency = "Response-Latency" + ResponseLocation = "Response-Location" + ResponseCacheControl = "Response-Cache-Control" + ResponseContentEncoding = "Response-Content-Encoding" + ResponseSetCookies = "Response-Set-Cookies" + ResponseTrailers = "Response-Trailers" +) + +var detailsMap = map[string]struct{}{ + RequestGeneral: {}, + RequestHeaders: {}, + RequestTiming: {}, + RequestContentLength: {}, + RequestContentType: {}, + RequestHost: {}, + RequestMethod: {}, + RequestPost: {}, + RequestProtocol: {}, + RequestQuery: {}, + RequestRemoteAddr: {}, + RequestURI: {}, + RequestAuthorization: {}, + RequestCookies: {}, + RequestReferer: {}, + RequestUserAgent: {}, + RequestXForwardedFor: {}, + RequestScheme: {}, + RequestPath: {}, + RequestTime: {}, + + ResponseGeneral: {}, + ResponseHeaders: {}, + ResponseTiming: {}, + ResponseContentLength: {}, + ResponseProtocol: {}, + ResponseStatus: {}, + ResponseStatusCode: {}, + ResponseContentType: {}, + ResponseTime: {}, + ResponseLatency: {}, + ResponseLocation: {}, + ResponseCacheControl: {}, + ResponseContentEncoding: {}, + ResponseSetCookies: {}, + ResponseTrailers: {}, +} + +const unset = "" + +func CollectHTTPMetrics(req *http.Request, resp *http.Response, details []string, result *interfaces.CaseResult) { + if result.Details == nil { + result.Details = make(map[string]any) + } + + for _, detail := range details { + if _, ok := detailsMap[detail]; !ok { + continue + } + + var value any + + // Request metrics + if strings.HasPrefix(detail, "Request") && req != nil { + switch detail { + case RequestGeneral: + value = fmt.Sprintf("%s %s %s", req.Method, req.URL.RequestURI(), req.Proto) + case RequestContentLength: + value = req.ContentLength + if value == 0 { + value = unset + } + case RequestContentType: + value = req.Header.Get("Content-Type") + if value == "" { + value = unset + } + case RequestHost: + value = req.Host + if value == "" { + value = unset + } + case RequestMethod: + value = req.Method + case RequestPost: + if req.Method == "POST" { + if body, err := io.ReadAll(req.Body); err == nil { + value = string(body) + req.Body = io.NopCloser(bytes.NewReader(body)) + } else { + value = "" + } + } else { + value = unset + } + case RequestProtocol: + value = req.Proto + case RequestQuery: + value = req.URL.Query().Encode() + if value == "" { + value = unset + } + case RequestRemoteAddr: + value = req.RemoteAddr + case RequestURI: + value = req.URL.RequestURI() + case RequestAuthorization: + value = req.Header.Get("Authorization") + if value == "" { + value = unset + } + case RequestCookies: + if cookies := req.Cookies(); len(cookies) > 0 { + value = cookies + } else { + value = unset + } + case RequestReferer: + value = req.Header.Get("Referer") + if value == "" { + value = unset + } + case RequestUserAgent: + value = req.Header.Get("User-Agent") + if value == "" { + value = unset + } + case RequestXForwardedFor: + value = req.Header.Get("X-Forwarded-For") + if value == "" { + value = unset + } + case RequestScheme: + if req.URL != nil { + value = req.URL.Scheme + } + if value == "" { + value = unset + } + case RequestPath: + value = req.URL.Path + case RequestTime: + value = time.Now().Format(time.RFC3339) + } + } + + // Response metrics + if strings.HasPrefix(detail, "Response") && resp != nil { + switch detail { + case ResponseGeneral: + value = fmt.Sprintf("%s %d %s", resp.Proto, resp.StatusCode, resp.Status) + case ResponseContentLength: + value = resp.ContentLength + if value == 0 { + value = unset + } + case ResponseProtocol: + value = resp.Proto + case ResponseStatus: + value = resp.Status + case ResponseStatusCode: + value = resp.StatusCode + case ResponseContentType: + value = resp.Header.Get("Content-Type") + if value == "" { + value = unset + } + case ResponseTime: + value = time.Now().Format(time.RFC3339) + case ResponseLatency: + value = result.Duration.String() + case ResponseLocation: + value = resp.Header.Get("Location") + if value == "" { + value = unset + } + case ResponseCacheControl: + value = resp.Header.Get("Cache-Control") + if value == "" { + value = unset + } + case ResponseContentEncoding: + value = resp.Header.Get("Content-Encoding") + if value == "" { + value = unset + } + case ResponseSetCookies: + if len(resp.Cookies()) > 0 { + value = resp.Cookies() + } else { + value = unset + } + case ResponseTrailers: + if len(resp.Trailer) > 0 { + value = resp.Trailer + } else { + value = unset + } + } + } + + result.Details[detail] = value + } +} diff --git a/internal/core/runner/pass/runner.go b/internal/core/runner/pass/runner.go deleted file mode 100644 index 252a1c2..0000000 --- a/internal/core/runner/pass/runner.go +++ /dev/null @@ -1,29 +0,0 @@ -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/templates/fake.go b/internal/core/runner/templates/fake.go new file mode 100644 index 0000000..32e133b --- /dev/null +++ b/internal/core/runner/templates/fake.go @@ -0,0 +1,105 @@ +package templates + +import ( + "math" + "strconv" + "strings" + + "github.com/brianvoe/gofakeit/v7" +) + +func fakeName(_ ...string) (any, error) { + return gofakeit.Name(), nil +} + +func fakeEmail(_ ...string) (any, error) { + return gofakeit.Email(), nil +} + +func fakePassword(args ...string) (any, error) { + length := 12 + lower := true + upper := true + numeric := true + special := false + space := false + + for _, arg := range args { + arg = strings.ToLower(strings.TrimSpace(arg)) + + switch arg { + case "nolower": + lower = false + case "noupper": + upper = false + case "nonumeric": + numeric = false + case "special": + special = true + case "space": + space = true + default: + if n, err := strconv.Atoi(arg); err == nil { + length = n + } + } + } + + return gofakeit.Password(lower, upper, numeric, special, space, length), nil +} + +func fakeInt(args ...string) (any, error) { + minInt, maxInt := -1_000_000_000_000_000, 1_000_000_000_000_000 + + if len(args) > 0 { + n, err := strconv.ParseInt(args[0], 10, 64) + if err == nil { + minInt = int(n) + } + + if len(args) > 1 { + n, err = strconv.ParseInt(args[1], 10, 64) + if err == nil { + maxInt = int(n) + } + } + } + + if minInt >= maxInt { + minInt, maxInt = maxInt, minInt-1 + } + + return gofakeit.IntRange(minInt, maxInt), nil +} + +func fakeUint(args ...string) (any, error) { + var minInt, maxInt uint64 = 0, math.MaxUint + + if len(args) > 0 { + n, err := strconv.ParseUint(args[0], 10, 64) + if err == nil { + minInt = n + } + + if len(args) > 1 { + n, err = strconv.ParseUint(args[1], 10, 64) + if err == nil { + maxInt = n + } + } + } + + if minInt >= maxInt { + minInt, maxInt = maxInt, minInt + } + + return gofakeit.UintRange(uint(minInt-1), uint(maxInt+1)), nil +} + +func fakeFloat(_ ...string) (any, error) { + return gofakeit.Float64(), nil +} + +func fakeBool(_ ...string) (any, error) { + return gofakeit.Bool(), nil +} diff --git a/internal/core/runner/templates/genetator.go b/internal/core/runner/templates/genetator.go new file mode 100644 index 0000000..ba5617b --- /dev/null +++ b/internal/core/runner/templates/genetator.go @@ -0,0 +1,172 @@ +package templates + +import ( + "fmt" + "regexp" + "strings" + "sync" +) + +type TemplateEngine struct { + generators map[string]GeneratorFunc + methods map[string]MethodFunc + mu sync.RWMutex + cache *sync.Map +} + +type GeneratorFunc func(args ...string) (any, error) + +type MethodFunc func(value any, args ...string) (any, error) + +func New() *TemplateEngine { + engine := &TemplateEngine{ + generators: make(map[string]GeneratorFunc), + methods: make(map[string]MethodFunc), + cache: &sync.Map{}, + } + + engine.RegisterGenerator("Fake.name", fakeName) + engine.RegisterGenerator("Fake.email", fakeEmail) + engine.RegisterGenerator("Fake.password", fakePassword) + engine.RegisterGenerator("Fake.int", fakeInt) + engine.RegisterGenerator("Fake.uint", fakeUint) + engine.RegisterGenerator("Fake.float", fakeFloat) + engine.RegisterGenerator("Fake.bool", fakeBool) + + engine.RegisterMethod("ToUpper", methodToUpper) + engine.RegisterMethod("ToLower", methodToLower) + engine.RegisterMethod("Trim", methodTrim) + + return engine +} + +func (e *TemplateEngine) RegisterGenerator(name string, fn GeneratorFunc) { + e.mu.Lock() + defer e.mu.Unlock() + e.generators[name] = fn +} + +func (e *TemplateEngine) RegisterMethod(name string, fn MethodFunc) { + e.mu.Lock() + defer e.mu.Unlock() + e.methods[name] = fn +} + +func (e *TemplateEngine) Execute(template string) (any, error) { + if isPureDirective(template) { + directive := extractDirective(template) + return e.processDirective(directive) + } + + re := regexp.MustCompile(`\{\{\s*(.*?)\s*}}`) + var result strings.Builder + lastIndex := 0 + + for _, match := range re.FindAllStringSubmatchIndex(template, -1) { + result.WriteString(template[lastIndex:match[0]]) + + inner := strings.Trim(template[match[2]:match[3]], " \t") + val, err := e.processDirective(inner) + if err != nil { + return nil, fmt.Errorf("template error: %v", err) + } + + result.WriteString(fmt.Sprintf("%v", val)) + lastIndex = match[1] + } + + result.WriteString(template[lastIndex:]) + + return result.String(), nil +} + +func isPureDirective(template string) bool { + trimmed := strings.TrimSpace(template) + if !strings.HasPrefix(trimmed, "{{") || !strings.HasSuffix(trimmed, "}}") { + return false + } + + content := trimmed[2 : len(trimmed)-2] + return !strings.Contains(content, "{{") && !strings.Contains(content, "}}") +} + +func extractDirective(template string) string { + return strings.TrimSpace(template[2 : len(template)-2]) +} + +func (e *TemplateEngine) processDirective(directive string) (any, error) { + if val, ok := e.cache.Load(directive); ok { + return val, nil + } + + parts := strings.Split(directive, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid directive format") + } + + var value any + var err error + processed := 0 + + for i := 0; i < len(parts); i++ { + current := strings.Join(parts[:i+1], ".") + if generator, ok := e.getGenerator(current); ok { + args := parts[i+1:] + var genArgs []string + for j := 0; j < len(args); j++ { + if strings.Contains(args[j], "(") { + break + } + genArgs = append(genArgs, args[j]) + } + value, err = generator(genArgs...) + if err != nil { + return nil, err + } + processed = i + len(genArgs) + 1 + break + } + } + + if value == nil { + return nil, fmt.Errorf("no generator found for directive") + } + + for i := processed; i < len(parts); i++ { + if strings.Contains(parts[i], "(") { + methodName := strings.Split(parts[i], "(")[0] + method, ok := e.getMethod(methodName) + if !ok { + return nil, fmt.Errorf("unknown method: %s", methodName) + } + + argsStr := strings.TrimSuffix(strings.SplitN(parts[i], "(", 2)[1], ")") + args := strings.Split(argsStr, ",") + for j := range args { + args[j] = strings.TrimSpace(args[j]) + } + + value, err = method(value, args...) + if err != nil { + return nil, err + } + } + } + + e.cache.Store(directive, value) + return value, nil +} + +func (e *TemplateEngine) getGenerator(name string) (GeneratorFunc, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + fn, ok := e.generators[name] + return fn, ok +} + +func (e *TemplateEngine) getMethod(name string) (MethodFunc, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + fn, ok := e.methods[name] + return fn, ok +} diff --git a/internal/core/runner/templates/methods.go b/internal/core/runner/templates/methods.go new file mode 100644 index 0000000..3e12e06 --- /dev/null +++ b/internal/core/runner/templates/methods.go @@ -0,0 +1,23 @@ +package templates + +import ( + "fmt" + "strings" +) + +func methodToUpper(value any, _ ...string) (any, error) { + return strings.ToUpper(fmt.Sprintf("%v", value)), nil +} + +func methodToLower(value any, _ ...string) (any, error) { + return strings.ToLower(fmt.Sprintf("%v", value)), nil +} + +func methodTrim(value any, args ...string) (any, error) { + cutset := " \t\n\r" + if len(args) > 0 { + cutset = args[0] + } + + return strings.Trim(fmt.Sprintf("%v", value), cutset), nil +} diff --git a/internal/core/runner/values/extractor.go b/internal/core/runner/values/extractor.go index 2afce84..e2b228c 100644 --- a/internal/core/runner/values/extractor.go +++ b/internal/core/runner/values/extractor.go @@ -17,7 +17,7 @@ func NewExtractor() *Extractor { return &Extractor{} } -func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, c tests.HttpCase, resp *http.Response, raw []byte, _ any) { +func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, c tests.HttpCase, resp *http.Response, body []byte) { group := c.Save.Group saveKey := func(key string) string { @@ -34,7 +34,7 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID string, // Json for key, jsonPath := range c.Save.Json { - val := gjson.GetBytes(raw, jsonPath).Value() + val := gjson.GetBytes(body, jsonPath).Value() ctx.SetTyped(saveKey(key), val, reflect.TypeOf(val).Kind()) } @@ -47,12 +47,12 @@ func (e *Extractor) Extract(ctx interfaces.ExecutionContext, manifestID 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)) + ctx.Set(saveKey("body"), string(body)) } else { if c.Save.Status { ctx.SetTyped(saveKey("status"), resp.StatusCode, reflect.Int) } else if c.Save.Body { - ctx.Set(saveKey("body"), string(raw)) + ctx.Set(saveKey("body"), string(body)) } } } diff --git a/ui/cli/console.go b/ui/cli/console.go index dfcbd8a..1e1c61c 100644 --- a/ui/cli/console.go +++ b/ui/cli/console.go @@ -101,3 +101,15 @@ func Done(msg string) { instance.Done(msg) } } + +func LogStyled(level ui.LogLevel, pairs ...LogPair) { + if isEnabled() { + instance.LogStyled(level, pairs...) + } +} + +func LogStyledf(level ui.LogLevel, format string, pairs ...LogPair) { + if isEnabled() { + instance.LogStyledf(level, format, pairs...) + } +} diff --git a/ui/cli/logger.go b/ui/cli/logger.go index 8b01525..847f11e 100644 --- a/ui/cli/logger.go +++ b/ui/cli/logger.go @@ -9,6 +9,19 @@ import ( "github.com/charmbracelet/lipgloss" ) +type LogPair struct { + Message string + Style *lipgloss.Style +} + +func (p LogPair) String() string { + if p.Style == nil { + return LogStyle.Render(p.Message) + } + + return p.Style.Render(p.Message) +} + func (u *UI) Log(level ui.LogLevel, msg string) { fmt.Print(formatLog(level, msg)) } @@ -17,6 +30,80 @@ func (u *UI) Logf(level ui.LogLevel, format string, args ...any) { u.Log(level, fmt.Sprintf(format, args...)) } +func (u *UI) LogStyled(level ui.LogLevel, pairs ...LogPair) { + var logBuilder strings.Builder + + var levelText string + var style lipgloss.Style + + switch level { + case ui.TypeDebug: + levelText = "DEBUG" + style = DebugStyle + case ui.TypeInfo: + levelText = "INFO" + style = InfoStyle + case ui.TypeWarning: + levelText = "WARN" + style = WarningStyle + case ui.TypeError: + levelText = "ERROR" + style = ErrorStyle + case ui.TypeFatal: + levelText = "FATAL" + style = FatalStyle + case ui.TypeSuccess: + levelText = "SUCCESS" + style = SuccessStyle + default: + levelText = "INFO" + style = InfoStyle + } + + paddedLevel := fmt.Sprintf("%-7s", levelText) + levelStyled := style.Render(paddedLevel) + timestamp := TimestampStyle.Render(time.Now().Format("15:04:05")) + + for _, pair := range pairs { + msg := pair.Message + if pair.Style != nil { + msg = pair.Style.Render(msg) + } else { + msg = LogStyle.Render(msg) + } + logBuilder.WriteString(msg) + } + + msg := logBuilder.String() + lines := strings.Split(msg, "\n") + baseIndent := len("14:30:45") + 1 + 7 + 2 + + for i := 1; i < len(lines); i++ { + lines[i] = strings.Repeat(" ", baseIndent) + lines[i] + } + msg = strings.Join(lines, "\n") + + message := LogStyle.Render(msg) + fmt.Printf("%s %s %s\n", timestamp, levelStyled, message) +} + +func (u *UI) LogStyledf(level ui.LogLevel, format string, pairs ...LogPair) { + args := make([]any, len(pairs)) + for i, pair := range pairs { + msg := pair.Message + if pair.Style != nil { + msg = pair.Style.Render(msg) + } else { + msg = LogStyle.Render(msg) + } + args[i] = msg + } + + formattedMsg := fmt.Sprintf(format, args...) + + u.LogStyled(level, LogPair{Message: formattedMsg}) +} + func (u *UI) Error(err error) { u.Log(ui.TypeError, err.Error()) } @@ -32,25 +119,25 @@ func formatLog(level ui.LogLevel, msg string) string { switch level { case ui.TypeDebug: levelText = "DEBUG" - style = debugStyle + style = DebugStyle case ui.TypeInfo: levelText = "INFO" - style = infoStyle + style = InfoStyle case ui.TypeWarning: levelText = "WARN" - style = warningStyle + style = WarningStyle case ui.TypeError: levelText = "ERROR" - style = errorStyle + style = ErrorStyle case ui.TypeFatal: levelText = "FATAL" - style = fatalStyle + style = FatalStyle case ui.TypeSuccess: levelText = "SUCCESS" - style = successStyle + style = SuccessStyle default: levelText = "INFO" - style = infoStyle + style = InfoStyle } paddedLevel := fmt.Sprintf("%-7s", levelText) @@ -64,8 +151,8 @@ func formatLog(level ui.LogLevel, msg string) string { } msg = strings.Join(lines, "\n") - timestamp := timestampStyle.Render(time.Now().Format("15:04:05")) - message := logStyle.Render(msg) + timestamp := TimestampStyle.Render(time.Now().Format("15:04:05")) + message := LogStyle.Render(msg) return fmt.Sprintf("%s %s %s\n", timestamp, levelStyled, message) } diff --git a/ui/cli/styles.go b/ui/cli/styles.go index 3470dbd..b223ef2 100644 --- a/ui/cli/styles.go +++ b/ui/cli/styles.go @@ -3,29 +3,29 @@ package cli import "github.com/charmbracelet/lipgloss" var ( - timestampStyle = lipgloss.NewStyle(). + TimestampStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#949494")) - logStyle = lipgloss.NewStyle(). + LogStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#e4e4e4")) - debugStyle = lipgloss.NewStyle(). + DebugStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#005fff")) - infoStyle = lipgloss.NewStyle(). + InfoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00afff")) - warningStyle = lipgloss.NewStyle(). + WarningStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#ff8700")) - errorStyle = lipgloss.NewStyle(). + ErrorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#d70000")). Bold(true) - fatalStyle = lipgloss.NewStyle(). + FatalStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF0000")). Bold(true) - successStyle = lipgloss.NewStyle(). + SuccessStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#5fd700")) )