diff --git a/cmd/cli/check/check.go b/cmd/cli/check/check.go index 22f1353..1243305 100644 --- a/cmd/cli/check/check.go +++ b/cmd/cli/check/check.go @@ -34,7 +34,7 @@ var cmdPlanCheck = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { opts, err := parseCheckPlanFlags(cmd, args) if err != nil { - cli.Errorf("Failed to parse provided values: %v", err) + cli.Errorf("Failed to parse provided save: %v", err) return } diff --git a/cmd/cli/generator/generate.go b/cmd/cli/generator/generate.go index 0a08b14..90c6050 100644 --- a/cmd/cli/generator/generate.go +++ b/cmd/cli/generator/generate.go @@ -22,7 +22,7 @@ var Cmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { opts, err := parseOptions(cmd) if err != nil { - cli.Errorf("Failed to parse provided values: %v", err) + cli.Errorf("Failed to parse provided save: %v", err) return } diff --git a/cmd/cli/run/run.go b/cmd/cli/run/run.go index 1c769c9..8aeff92 100644 --- a/cmd/cli/run/run.go +++ b/cmd/cli/run/run.go @@ -25,7 +25,7 @@ var Cmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { opts, err := parseOptions(cmd) if err != nil { - cli.Errorf("Failed to parse provided values: %v", err) + cli.Errorf("Failed to parse provided save: %v", err) return } diff --git a/examples/plan/plan.yaml b/examples/plan/plan.yaml index 0db4b95..d858a60 100644 --- a/examples/plan/plan.yaml +++ b/examples/plan/plan.yaml @@ -5,7 +5,7 @@ metadata: spec: stages: - name: "Preparation..." - description: "First stage with loading values to context" + description: "First stage with loading save to context" manifests: - Values.simple-value - name: "Starting and checking server" diff --git a/examples/simple-http-tests-1/http_test.yaml b/examples/simple-http-tests-1/http_test.yaml new file mode 100644 index 0000000..7247d12 --- /dev/null +++ b/examples/simple-http-tests-1/http_test.yaml @@ -0,0 +1,26 @@ +version: v1 +kind: HttpTest +metadata: + name: simple-test-example-1 + namespace: simple-http-tests-1 + +spec: + target: http://127.0.0.1:8081 + cases: + + - name: Create New Array of User + method: POST + endpoint: /users-batch + assert: + - target: status + equals: 201 + body: + users: + __repeat: 2 + __template: + name: "{{ Fake.name }}" + email: "{{ Fake.email }}" + age: "{{ Fake.uint.10.100 }}" + address: + street: "{{ Fake.address }}" + number: "{{ Regex(\"^[a-z]{5,10}@[a-z]{5,10}\\.(com|net|org)$\") }}" \ No newline at end of file diff --git a/examples/values/values.yaml b/examples/values/values.yaml index 9f28b99..9e6e1be 100644 --- a/examples/values/values.yaml +++ b/examples/values/values.yaml @@ -1,7 +1,7 @@ version: v1 kind: Values metadata: - name: simple-values + name: simple-save spec: users: username: ["Max", "Carl", "John", "Alex", "Uli"] diff --git a/go.mod b/go.mod index 28bf222..7c50bba 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/spf13/viper v1.20.1 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 + golang.org/x/text v0.23.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -96,6 +97,5 @@ require ( golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.33.0 // indirect golang.org/x/term v0.32.0 // indirect - golang.org/x/text v0.23.0 // indirect google.golang.org/protobuf v1.36.6 // indirect ) diff --git a/internal/collections/priority_queue_test.go b/internal/collections/priority_queue_test.go new file mode 100644 index 0000000..50070ec --- /dev/null +++ b/internal/collections/priority_queue_test.go @@ -0,0 +1,32 @@ +package collections + +import ( + "slices" + "sort" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPriorityQueue(t *testing.T) { + pq := NewPriorityQueue(func(a, b int) bool { + return a > b + }) + + values := []int{ + 1, 2, 3, 10, 3, 145, 94, 173, 833, + } + + for _, v := range values { + pq.Push(v) + } + + sort.Ints(values) + slices.Reverse(values) + + require.Equal(t, len(values), pq.Len()) + + for _, x := range values { + require.Equal(t, x, pq.Pop()) + } +} diff --git a/internal/core/manifests/kinds/tests/base.go b/internal/core/manifests/kinds/tests/base.go index 152defa..25ef721 100644 --- a/internal/core/manifests/kinds/tests/base.go +++ b/internal/core/manifests/kinds/tests/base.go @@ -13,7 +13,7 @@ type HttpCase struct { 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,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"` + 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"` @@ -28,12 +28,13 @@ type Assert struct { } type Save struct { - Json map[string]string `yaml:"json,omitempty" json:"json,omitempty" validate:"omitempty,dive,keys,endkeys"` - Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty,dive,keys,endkeys"` - Status bool `yaml:"status,omitempty" json:"status,omitempty" validate:"omitempty,boolean"` - Body bool `yaml:"body,omitempty" json:"body,omitempty" validate:"omitempty,boolean"` - All bool `yaml:"all,omitempty" json:"all,omitempty" validate:"omitempty,boolean"` - Group string `yaml:"group,omitempty" json:"group,omitempty" validate:"omitempty,min=1,max=100"` + Request *SaveEntry `yaml:"request,omitempty" json:"request,omitempty" validate:"omitempty"` + Response *SaveEntry `yaml:"response,omitempty" json:"response,omitempty" validate:"omitempty"` +} + +type SaveEntry struct { + Body map[string]string `yaml:"body,omitempty" json:"body,omitempty" validate:"omitempty,min=1,max=20,dive,keys,endkeys"` + Headers []string `yaml:"headers,omitempty" json:"headers,omitempty" validate:"omitempty,min=1,max=20"` } type Pass struct { diff --git a/internal/core/runner/assert/runner.go b/internal/core/runner/assert/runner.go index 7fdefa8..812e7c2 100644 --- a/internal/core/runner/assert/runner.go +++ b/internal/core/runner/assert/runner.go @@ -3,6 +3,7 @@ package assert import ( "bytes" "encoding/json" + "errors" "fmt" "net/http" "reflect" @@ -37,29 +38,32 @@ func NewRunner() *Runner { } func (r *Runner) Assert(ctx interfaces.ExecutionContext, asserts []*tests.Assert, resp *http.Response, body []byte) error { + var err error + for _, assert := range asserts { switch assert.Target { case Status.String(): - return r.assertStatus(ctx, assert, resp) + err = errors.Join(err, r.assertStatus(ctx, assert, resp)) case Body.String(): - return r.assertBody(ctx, assert, resp, body) + err = errors.Join(err, r.assertBody(ctx, assert, resp, body)) case Headers.String(): - return r.assertHeaders(ctx, assert, resp) + err = errors.Join(err, r.assertHeaders(ctx, assert, resp)) default: return fmt.Errorf("assert failed: unknown assert target %s", assert.Target) } } - return nil + + return err } func (r *Runner) assertStatus(_ interfaces.ExecutionContext, assert *tests.Assert, resp *http.Response) error { if assert.Equals != nil { - expectedCode, ok := assert.Equals.(float64) + expectedCode, ok := assert.Equals.(int) if !ok { - return fmt.Errorf("expected status correct value got %v", assert.Equals) + return fmt.Errorf("expected status type [int] got %T", assert.Equals) } - if resp.StatusCode != int(expectedCode) { + if resp.StatusCode != expectedCode { return fmt.Errorf("expected status code %v, got %v", expectedCode, resp.StatusCode) } } @@ -114,7 +118,7 @@ func (r *Runner) assertBody(_ interfaces.ExecutionContext, assert *tests.Assert, if assert.Contains != "" { if !bytes.Contains(body, []byte(assert.Contains)) { - return fmt.Errorf("expected %v in body", assert.Contains) + return fmt.Errorf("expected '%v' in body", assert.Contains) } return nil @@ -126,7 +130,7 @@ func (r *Runner) assertBody(_ interfaces.ExecutionContext, assert *tests.Assert, 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("expected map assertion got %v", assert.Equals) + return fmt.Errorf("expected map type assertion got %T", assert.Equals) } else { for key, expectedVal := range equals { actualVal := resp.Header.Get(key) @@ -152,7 +156,7 @@ func (r *Runner) assertHeaders(_ interfaces.ExecutionContext, assert *tests.Asse } if !found { - return fmt.Errorf("expected header %v but not found", assert.Contains) + return fmt.Errorf("expected header contains %v but not found", assert.Contains) } } diff --git a/internal/core/runner/cli/output.go b/internal/core/runner/cli/output.go index 9a21b1f..1d75a2a 100644 --- a/internal/core/runner/cli/output.go +++ b/internal/core/runner/cli/output.go @@ -161,7 +161,7 @@ func (o *Output) DumpValues(values map[string]any) { rows = append(rows, fmt.Sprintf("%v: %v", k, v)) } - cli.Printf("Damping values: \n%s", strings.Join(rows, "\n")) + cli.Printf("Damping save: \n%s", strings.Join(rows, "\n")) } } diff --git a/internal/core/runner/depends/dependencies.go b/internal/core/runner/depends/dependencies.go index b4a7bee..9c97f37 100644 --- a/internal/core/runner/depends/dependencies.go +++ b/internal/core/runner/depends/dependencies.go @@ -11,10 +11,9 @@ import ( ) var priorityOrder = map[string]int{ - "Values": 100, - "ConfigMap": 90, - "Target": 50, - "Service": 30, + manifests.ValuesKind: 100, + manifests.ServerKind: 40, + manifests.ServiceKind: 30, } type GraphResult struct { diff --git a/internal/core/runner/executor/executors/http.go b/internal/core/runner/executor/executors/http.go index 86d8b84..e0d6239 100644 --- a/internal/core/runner/executor/executors/http.go +++ b/internal/core/runner/executor/executors/http.go @@ -19,24 +19,26 @@ import ( "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/values" + "github.com/apiqube/cli/internal/core/runner/save" ) const httpExecutorOutputPrefix = "HTTP Executor:" +const httpExecutorRunTimeout = time.Second * 30 + var _ interfaces.Executor = (*HTTPExecutor)(nil) type HTTPExecutor struct { client *http.Client - extractor *values.Extractor + extractor *save.Extractor assertor *assert.Runner passer *form.Runner } func NewHTTPExecutor() *HTTPExecutor { return &HTTPExecutor{ - client: &http.Client{Timeout: 30 * time.Second}, - extractor: values.NewExtractor(), + client: &http.Client{Timeout: httpExecutorRunTimeout}, + extractor: save.NewExtractor(), assertor: assert.NewRunner(), passer: form.NewRunner(), } @@ -96,7 +98,6 @@ 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), @@ -114,25 +115,13 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c 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 == "" { - 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 - } - } + // Building url to testing target + url := buildHttpURL(c.Url, man.Spec.Target, c.Endpoint) + // Applying save from Pass declaration 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) @@ -140,7 +129,7 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c reqBody := &bytes.Buffer{} if body != nil { - if err := json.NewEncoder(reqBody).Encode(body); err != 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) } @@ -156,13 +145,14 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c req.Header.Set(k, v) } - timeout := c.Timeout - if timeout == 0 { - timeout = 5 * time.Second + if c.Timeout > 0 { + e.client.Timeout = c.Timeout } - client := &http.Client{Timeout: timeout} - resp, err = client.Do(req) + start := time.Now() + resp, err = e.client.Do(req) + caseResult.Duration = time.Since(start) + if err != nil { if errors.Is(err, context.DeadlineExceeded) { caseResult.Errors = append(caseResult.Errors, "request timed out") @@ -185,14 +175,13 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c return fmt.Errorf("read response body failed: %w", err) } + e.extractor.Extract(ctx, man, c.HttpCase, resp, reqBody.Bytes(), respBody, caseResult) if c.Save != nil { - 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) + output.Logf(interfaces.InfoLevel, "%s data extraction for %s %s", httpExecutorOutputPrefix, man.GetName(), c.Name) } if c.Assert != nil { - output.Logf(interfaces.InfoLevel, "%s reponse asserting for %s %s ", httpExecutorOutputPrefix, man.GetName(), man.Spec.Target) + output.Logf(interfaces.InfoLevel, "%s reponse asserting for %s %s", httpExecutorOutputPrefix, man.GetName(), c.Name) if err = e.assertor.Assert(ctx, c.Assert, resp, respBody); err != nil { caseResult.Assert = "no" @@ -209,3 +198,20 @@ func (e *HTTPExecutor) runCase(ctx interfaces.ExecutionContext, man *api.Http, c return nil } + +func buildHttpURL(url, target, endpoint string) string { + if url == "" { + baseUrl := strings.TrimRight(target, "/") + ep := strings.TrimLeft(endpoint, "/") + + if baseUrl != "" && ep != "" { + url = baseUrl + "/" + ep + } else if baseUrl != "" { + url = baseUrl + } else { + url = ep + } + } + + return url +} diff --git a/internal/core/runner/form/README.md b/internal/core/runner/form/README.md new file mode 100644 index 0000000..b110f91 --- /dev/null +++ b/internal/core/runner/form/README.md @@ -0,0 +1,144 @@ +# Form Runner Package + +This package provides a powerful and flexible system for processing forms and templates for ApiQube CLI. + +## Architecture + +The package is built on a modular architecture with clear separation of concerns: + +### Main Components + +#### 1. Runner (`runner_new.go`) +The main class that coordinates all form processing. It provides a simple API for: +- Processing strings with templates (`Apply`) +- Processing complex data structures (`ApplyBody`) +- Processing HTTP headers (`MapHeaders`) + +#### 2. Processors (`processors.go`) +A system of processors for handling different data types: +- **StringProcessor**: Processes string values and templates +- **MapProcessor**: Processes objects (map[string]any) +- **ArrayProcessor**: Processes arrays +- **CompositeProcessor**: Combines all processors + +#### 3. Template Resolver (`template_resolver.go`) +Responsible for resolving templates: +- Supports contextual variables +- Supports Body references (`Body.field.subfield`) +- Integrates with a template engine for functions + +#### 4. Value Extractor (`value_extractor.go`) +Extracts values from nested data structures: +- Supports dot notation paths +- Supports array indices +- Supports dynamic indices (`#`) + +#### 5. Directive Executor (`directive_executor.go`) +Executes special directives: +- Registers and executes directives +- Checks dependencies +- Extensible directive system + +#### 6. Reference Resolver (`reference_resolver.go`) +Resolves references between data elements: +- Supports cyclic references +- Recursive structure processing +- Context-aware resolution + +## Interfaces + +All components implement well-defined interfaces (`interfaces.go`): + +```go +type Processor interface { + Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any +} + +type TemplateResolver interface { + Resolve(ctx interfaces.ExecutionContext, template string, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) +} + +type DirectiveExecutor interface { + Execute(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) + CanHandle(value any) bool +} +// ... and others +``` + +## Usage + +### Basic Usage + +```go +runner := NewRunner() +ctx := // your ExecutionContext + +// Process a string +result := runner.Apply(ctx, "Hello {{ username }}", nil) + +// Process a complex structure +body := map[string]any{ + "user": map[string]any{ + "name": "{{ username }}", + "email": "{{ Fake.email }}", + }, +} +processed := runner.ApplyBody(ctx, body, nil) + +// Process headers +headers := map[string]string{ + "Authorization": "Bearer {{ token }}", +} +processedHeaders := runner.MapHeaders(ctx, headers, nil) +``` + +### Directives + +Special directives are supported for advanced logic: + +```go +body := map[string]any{ + "__repeat": 3, + "__template": map[string]any{ + "id": "{{ Fake.int }}", + "name": "User #{{ # }}", + }, +} +// Result: array of 3 objects with unique data +``` + +### Extending Functionality + +```go +// Register a new directive +type MyDirective struct{} +func (d *MyDirective) Name() string { return "mydir" } +func (d *MyDirective) Dependencies() []string { return []string{} } +func (d *MyDirective) Execute(...) (any, error) { /* your logic */ } + +runner.RegisterDirective(&MyDirective{}) +``` + +## Refactoring Benefits + +1. **Modularity**: Each component has a clear responsibility +2. **Testability**: Components are easy to test in isolation +3. **Extensibility**: Easy to add new processors and directives +4. **Readability**: Code is more understandable and structured +5. **Performance**: Optimized data processing +6. **Reliability**: Better error handling and edge case management + +## Testing + +The package includes a comprehensive test suite (`runner_test.go`) with mock objects for testing all components. + +```bash +go test ./internal/core/runner/form/... +``` + +## Migration + +To migrate from the old version: +1. Replace imports with new files +2. The API remains compatible +3. Additional features are available via new methods diff --git a/internal/core/runner/form/directive_executor.go b/internal/core/runner/form/directive_executor.go new file mode 100644 index 0000000..252974f --- /dev/null +++ b/internal/core/runner/form/directive_executor.go @@ -0,0 +1,74 @@ +package form + +import ( + "fmt" + "strings" + + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// defaultDirectiveExecutor implements DirectiveExecutor interface +type defaultDirectiveExecutor struct { + registry map[string]DirectiveHandler + processor Processor +} + +func newDefaultDirectiveExecutor(processor Processor) *defaultDirectiveExecutor { + executor := &defaultDirectiveExecutor{ + registry: make(map[string]DirectiveHandler), + processor: processor, + } + + // Register built-in directives + executor.RegisterDirective(newRepeatDirective()) + + return executor +} + +func (e *defaultDirectiveExecutor) RegisterDirective(handler DirectiveHandler) { + e.registry[handler.Name()] = handler +} + +func (e *defaultDirectiveExecutor) CanHandle(value any) bool { + valMap, ok := value.(map[string]any) + if !ok { + return false + } + + for k := range valMap { + if strings.HasPrefix(k, "__") { + return true + } + } + return false +} + +func (e *defaultDirectiveExecutor) Execute(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) { + valMap, ok := value.(map[string]any) + if !ok { + return nil, fmt.Errorf("directive executor: expected map input") + } + + for k := range valMap { + if strings.HasPrefix(k, "__") { + dirName := strings.TrimPrefix(k, "__") + if handler, exists := e.registry[dirName]; exists { + // Check dependencies + e.checkDependencies(handler, valMap) + + return handler.Execute(ctx, e.processor, valMap, pass, processedData, indexStack) + } + } + } + + return value, nil +} + +func (e *defaultDirectiveExecutor) checkDependencies(handler DirectiveHandler, valMap map[string]any) { + for _, dep := range handler.Dependencies() { + if _, ok := valMap["__"+dep]; !ok { + fmt.Printf("[WARN] Directive __%s requires __%s\n", handler.Name(), dep) + } + } +} diff --git a/internal/core/runner/form/directives.go b/internal/core/runner/form/directives.go new file mode 100644 index 0000000..fab7a78 --- /dev/null +++ b/internal/core/runner/form/directives.go @@ -0,0 +1,112 @@ +package form + +import ( + "fmt" + "strconv" + + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// DirectiveHandler defines the interface for directive handlers +type DirectiveHandler interface { + Execute(ctx interfaces.ExecutionContext, runner Processor, input any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) + Name() string + Dependencies() []string +} + +// repeatDirective implements the __repeat directive +type repeatDirective struct { + name string + dependencies []string +} + +func newRepeatDirective() *repeatDirective { + return &repeatDirective{ + name: "repeat", + dependencies: []string{"template"}, + } +} + +func (r *repeatDirective) Name() string { + return r.name +} + +func (r *repeatDirective) Dependencies() []string { + return r.dependencies +} + +func (r *repeatDirective) Execute(ctx interfaces.ExecutionContext, processor Processor, input any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) { + inputMap, ok := input.(map[string]any) + if !ok { + return nil, fmt.Errorf("__repeat: expected map input") + } + + tmpl, ok := inputMap["__template"] + if !ok { + return nil, fmt.Errorf("__repeat: missing __template") + } + + countRaw, ok := inputMap["__repeat"] + if !ok { + return nil, fmt.Errorf("__repeat: missing repeat count") + } + + count, err := strconv.Atoi(fmt.Sprintf("%v", countRaw)) + if err != nil { + return nil, fmt.Errorf("__repeat: invalid repeat count: %v", err) + } + + results := make([]any, 0, count) + // Prepare or reuse processedData array for this field if possible + var arrKey string + for k := range inputMap { + if k != "__repeat" && k != "__template" { + arrKey = k + break + } + } + if arrKey == "" { + arrKey = "users" // fallback + } + if processedData != nil { + if _, ok = processedData[arrKey]; !ok { + processedData[arrKey] = make([]any, count) + } + } + for i := 0; i < count; i++ { + newStack := append(indexStack[:len(indexStack):len(indexStack)], i) + processed := processor.Process(ctx, tmpl, pass, processedData, newStack) + results = append(results, processed) + // Если processed — map, сразу положить его в processedData[arrKey][i] + if processedData != nil { + var arr []any + if arr, ok = processedData[arrKey].([]any); ok && i < len(arr) { + arr[i] = processed + processedData[arrKey] = arr + } + } + } + + // После генерации всех элементов users, пройтись по каждому и вычислить вложенные map-поля + if processedData != nil { + var arr []any + if arr, ok = processedData[arrKey].([]any); ok { + for i, elem := range arr { + var m map[string]any + if m, ok = elem.(map[string]any); ok { + for k, v := range m { + if submap, is := v.(map[string]any); is { + // Переобработать вложенную map с актуальным processedData + m[k] = processor.Process(ctx, submap, pass, mergeProcessedData(processedData, m), []int{i}) + } + } + arr[i] = m + } + } + processedData[arrKey] = arr + } + } + + return results, nil +} diff --git a/internal/core/runner/form/interfaces.go b/internal/core/runner/form/interfaces.go new file mode 100644 index 0000000..50e55d4 --- /dev/null +++ b/internal/core/runner/form/interfaces.go @@ -0,0 +1,32 @@ +package form + +import ( + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// Processor defines the interface for processing different types of values +type Processor interface { + Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any +} + +// TemplateResolver defines the interface for resolving templates +type TemplateResolver interface { + Resolve(ctx interfaces.ExecutionContext, template string, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) +} + +// DirectiveExecutor defines the interface for executing directives +type DirectiveExecutor interface { + Execute(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) + CanHandle(value any) bool +} + +// ReferenceResolver defines the interface for resolving references +type ReferenceResolver interface { + Resolve(ctx interfaces.ExecutionContext, value any, processedData map[string]any, pass []*tests.Pass, indexStack []int) any +} + +// ValueExtractor defines the interface for extracting values from nested structures +type ValueExtractor interface { + Extract(path []string, data any, indexStack []int) (any, bool) +} diff --git a/internal/core/runner/form/merge.go b/internal/core/runner/form/merge.go new file mode 100644 index 0000000..ad22421 --- /dev/null +++ b/internal/core/runner/form/merge.go @@ -0,0 +1,22 @@ +package form + +// mergeProcessedData merges two maps (base and overlay), overlaying keys over base. +func mergeProcessedData(base map[string]any, overlay map[string]any) map[string]any { + if base == nil && overlay == nil { + return nil + } + if base == nil { + return overlay + } + if overlay == nil { + return base + } + result := make(map[string]any, len(base)+len(overlay)) + for k, v := range base { + result[k] = v + } + for k, v := range overlay { + result[k] = v + } + return result +} diff --git a/internal/core/runner/form/processors.go b/internal/core/runner/form/processors.go new file mode 100644 index 0000000..96013e5 --- /dev/null +++ b/internal/core/runner/form/processors.go @@ -0,0 +1,150 @@ +package form + +import ( + "fmt" + "regexp" + "strings" + + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// StringProcessor handles string value processing +type StringProcessor struct { + templateResolver TemplateResolver +} + +func NewStringProcessor(templateResolver TemplateResolver) *StringProcessor { + return &StringProcessor{ + templateResolver: templateResolver, + } +} + +func (p *StringProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { + str, ok := value.(string) + if !ok { + return value + } + + if p.isCompleteTemplate(str) { + result, _ := p.templateResolver.Resolve(ctx, str, pass, processedData, indexStack) + return result + } + + return p.processMixedString(ctx, str, pass, processedData, indexStack) +} + +func (p *StringProcessor) isCompleteTemplate(str string) bool { + str = strings.TrimSpace(str) + return strings.HasPrefix(str, "{{") && strings.HasSuffix(str, "}}") && strings.Count(str, "{{") == 1 +} + +func (p *StringProcessor) processMixedString(ctx interfaces.ExecutionContext, str string, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { + templateRegex := regexp.MustCompile(`\{\{\s*(.*?)\s*}}`) + var err error + + result := templateRegex.ReplaceAllStringFunc(str, func(match string) string { + // Extract inner content without brackets + inner := strings.TrimSpace(match[2 : len(match)-2]) + processed, e := p.templateResolver.Resolve(ctx, inner, pass, processedData, indexStack) + if e != nil { + err = e + return match + } + return fmt.Sprint(processed) + }) + + if err != nil { + return str + } + return result +} + +// MapProcessor handles map value processing +type MapProcessor struct { + valueProcessor Processor + directiveHandler DirectiveExecutor +} + +func NewMapProcessor(valueProcessor Processor, directiveHandler DirectiveExecutor) *MapProcessor { + return &MapProcessor{ + valueProcessor: valueProcessor, + directiveHandler: directiveHandler, + } +} + +func (p *MapProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { + m, ok := value.(map[string]any) + if !ok { + return value + } + + // Check for directives first + if p.directiveHandler.CanHandle(value) { + if result, err := p.directiveHandler.Execute(ctx, value, pass, processedData, indexStack); err == nil { + return result + } + } + + // Two-pass processing: first pass for non-Body fields, second for Body-dependent fields + result := make(map[string]any, len(m)) + // Always pass merged processedData (global + current object) for every field + for k, v := range m { + result[k] = p.valueProcessor.Process(ctx, v, pass, mergeProcessedData(processedData, result), indexStack) + } + return result +} + +// ArrayProcessor handles array value processing +type ArrayProcessor struct { + valueProcessor Processor +} + +func NewArrayProcessor(valueProcessor Processor) *ArrayProcessor { + return &ArrayProcessor{ + valueProcessor: valueProcessor, + } +} + +func (p *ArrayProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { + arr, ok := value.([]any) + if !ok { + return value + } + + result := make([]any, len(arr)) + for i, item := range arr { + result[i] = p.valueProcessor.Process(ctx, item, pass, processedData, indexStack) + } + return result +} + +// CompositeProcessor combines multiple processors +type CompositeProcessor struct { + stringProcessor *StringProcessor + mapProcessor *MapProcessor + arrayProcessor *ArrayProcessor +} + +func NewCompositeProcessor(templateResolver TemplateResolver, directiveHandler DirectiveExecutor) *CompositeProcessor { + processor := &CompositeProcessor{} + + processor.stringProcessor = NewStringProcessor(templateResolver) + processor.mapProcessor = NewMapProcessor(processor, directiveHandler) + processor.arrayProcessor = NewArrayProcessor(processor) + + return processor +} + +func (p *CompositeProcessor) Process(ctx interfaces.ExecutionContext, value any, pass []*tests.Pass, processedData map[string]any, indexStack []int) any { + switch v := value.(type) { + case string: + return p.stringProcessor.Process(ctx, v, pass, processedData, indexStack) + case map[string]any: + return p.mapProcessor.Process(ctx, v, pass, processedData, indexStack) + case []any: + return p.arrayProcessor.Process(ctx, v, pass, processedData, indexStack) + default: + return v + } +} diff --git a/internal/core/runner/form/reference_resolver.go b/internal/core/runner/form/reference_resolver.go new file mode 100644 index 0000000..f148207 --- /dev/null +++ b/internal/core/runner/form/reference_resolver.go @@ -0,0 +1,68 @@ +package form + +import ( + "regexp" + "strings" + + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// DefaultReferenceResolver implements ReferenceResolver interface +type DefaultReferenceResolver struct { + templateResolver TemplateResolver +} + +func NewDefaultReferenceResolver(templateResolver TemplateResolver) *DefaultReferenceResolver { + return &DefaultReferenceResolver{ + templateResolver: templateResolver, + } +} + +func (r *DefaultReferenceResolver) Resolve(ctx interfaces.ExecutionContext, value any, processedData map[string]any, pass []*tests.Pass, indexStack []int) any { + switch v := value.(type) { + case string: + return r.resolveStringReferences(ctx, v, processedData, pass, indexStack) + case map[string]any: + return r.resolveMapReferences(ctx, v, processedData, pass, indexStack) + case []any: + return r.resolveArrayReferences(ctx, v, processedData, pass, indexStack) + default: + return v + } +} + +func (r *DefaultReferenceResolver) resolveStringReferences(ctx interfaces.ExecutionContext, str string, processedData map[string]any, pass []*tests.Pass, indexStack []int) any { + if !strings.Contains(str, "{{") { + return str + } + + templateRegex := regexp.MustCompile(`\{\{\s*(Body\..*?)\s*}}`) + matches := templateRegex.FindAllStringSubmatch(str, -1) + + if len(matches) > 0 { + // If we found Body references, resolve the entire string as template + result, _ := r.templateResolver.Resolve(ctx, str, pass, processedData, indexStack) + return result + } + + return str +} + +func (r *DefaultReferenceResolver) resolveMapReferences(ctx interfaces.ExecutionContext, m map[string]any, processedData map[string]any, pass []*tests.Pass, indexStack []int) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + result[k] = r.Resolve(ctx, v, processedData, pass, indexStack) + } + return result +} + +func (r *DefaultReferenceResolver) resolveArrayReferences(ctx interfaces.ExecutionContext, arr []any, processedData map[string]any, pass []*tests.Pass, indexStack []int) []any { + result := make([]any, len(arr)) + for i, item := range arr { + // Add current index to stack for nested array processing + newIndexStack := append(indexStack, i) + result[i] = r.Resolve(ctx, item, processedData, pass, newIndexStack) + } + return result +} diff --git a/internal/core/runner/form/runner.go b/internal/core/runner/form/runner.go index 55937eb..1e86777 100644 --- a/internal/core/runner/form/runner.go +++ b/internal/core/runner/form/runner.go @@ -1,6 +1,7 @@ package form import ( + "encoding/json" "fmt" "regexp" "strings" @@ -10,123 +11,144 @@ import ( "github.com/apiqube/cli/internal/core/runner/templates" ) +// Runner is the main form processing engine type Runner struct { - fakeEngine *templates.TemplateEngine + processor Processor + templateResolver TemplateResolver + referenceResolver ReferenceResolver + templateEngine *templates.TemplateEngine } +// NewRunner creates a new form runner with all dependencies properly wired func NewRunner() *Runner { + templateEngine := templates.New() + valueExtractor := NewDefaultValueExtractor() + templateResolver := NewDefaultTemplateResolver(templateEngine, valueExtractor) + referenceResolver := NewDefaultReferenceResolver(templateResolver) + + // Create processor with circular dependency resolution + var processor Processor + directiveExecutor := newDefaultDirectiveExecutor(nil) // Will be set later + processor = NewCompositeProcessor(templateResolver, directiveExecutor) + + // Now set the processor in directive executor + directiveExecutor.processor = processor + return &Runner{ - fakeEngine: templates.New(), + processor: processor, + templateResolver: templateResolver, + referenceResolver: referenceResolver, + templateEngine: templateEngine, } } -func (r *Runner) Apply(ctx interfaces.ExecutionContext, input string, pass []tests.Pass) string { +// Apply processes a string input with pass mappings and template resolution +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) - } - } + // Apply pass mappings first + result = r.applyPassMappings(ctx, result, pass) - return match - }) + // Apply template resolution + result = r.applyTemplateResolution(ctx, result) return result } -func (r *Runner) ApplyBody(ctx interfaces.ExecutionContext, body map[string]any, pass []tests.Pass) map[string]any { +// ApplyBody processes a map body with full form processing capabilities +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 + // Process the body using the main processor + processed := r.processor.Process(ctx, body, pass, nil, []int{}) + + // Convert result to map + if processedMap, ok := processed.(map[string]any); ok { + // Resolve references in the processed data + resolved := r.referenceResolver.Resolve(ctx, processedMap, processedMap, pass, []int{}) + + if resolvedMap, ok := resolved.(map[string]any); ok { + // Debug output (can be removed or made configurable) + if data, err := json.MarshalIndent(resolvedMap, "", " "); err == nil { + fmt.Println(string(data)) + } + return resolvedMap } } - return result + return body } -func (r *Runner) MapHeaders(ctx interfaces.ExecutionContext, headers map[string]string, pass []tests.Pass) map[string]string { +// MapHeaders processes header mappings +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) + processedKey := r.processHeaderValue(ctx, key, pass) + processedValue := r.processHeaderValue(ctx, value, pass) result[processedKey] = processedValue } - return result } -func (r *Runner) renderTemplate(_ interfaces.ExecutionContext, raw string) any { - if !strings.Contains(raw, "{{") { - return raw +// Private helper methods + +func (r *Runner) applyPassMappings(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)) + } + } + } + } } + return result +} + +func (r *Runner) applyTemplateResolution(ctx interfaces.ExecutionContext, input string) string { + reg := regexp.MustCompile(`\{\{\s*([^}\s]+)\s*}}`) + return reg.ReplaceAllStringFunc(input, func(match string) string { + key := strings.Trim(match, "{} \t") - if strings.Contains(raw, "Fake") { - result, err := r.fakeEngine.Execute(raw) - if err != nil { - return raw + if val, ok := ctx.Get(key); ok { + return fmt.Sprintf("%v", val) } - return result - } + if strings.HasPrefix(key, "Fake.") { + if val, err := r.templateEngine.Execute(match); err == nil { + return fmt.Sprintf("%v", val) + } + } - return raw + return match + }) } -func (r *Runner) applyArray(ctx interfaces.ExecutionContext, arr []any, pass []tests.Pass) []any { - if arr == nil { - return nil +func (r *Runner) processHeaderValue(ctx interfaces.ExecutionContext, value string, pass []*tests.Pass) string { + processed := r.processor.Process(ctx, value, pass, nil, []int{}) + if str, ok := processed.(string); ok { + return str } + return fmt.Sprintf("%v", processed) +} - 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 - } +// RegisterDirective allows registering custom directives +func (r *Runner) RegisterDirective(handler DirectiveHandler) { + if executor, ok := r.processor.(*CompositeProcessor).mapProcessor.directiveHandler.(*defaultDirectiveExecutor); ok { + executor.RegisterDirective(handler) } - return result +} + +// GetTemplateEngine returns the underlying template engine for advanced usage +func (r *Runner) GetTemplateEngine() *templates.TemplateEngine { + return r.templateEngine } diff --git a/internal/core/runner/form/runner_test.go b/internal/core/runner/form/runner_test.go new file mode 100644 index 0000000..3f42802 --- /dev/null +++ b/internal/core/runner/form/runner_test.go @@ -0,0 +1,239 @@ +package form + +import ( + "context" + "reflect" + "testing" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/runner/interfaces" +) + +// MockExecutionContext for testing +type MockExecutionContext struct { + context.Context + data map[string]any +} + +func NewMockExecutionContext() *MockExecutionContext { + return &MockExecutionContext{ + Context: context.Background(), + data: make(map[string]any), + } +} + +func (m *MockExecutionContext) Set(key string, value any) { + m.data[key] = value +} + +func (m *MockExecutionContext) Get(key string) (any, bool) { + val, ok := m.data[key] + return val, ok +} + +func (m *MockExecutionContext) Delete(key string) { + delete(m.data, key) +} + +func (m *MockExecutionContext) All() map[string]any { + return m.data +} + +func (m *MockExecutionContext) SetTyped(key string, value any, kind reflect.Kind) { + m.data[key] = value +} + +func (m *MockExecutionContext) GetTyped(key string) (any, reflect.Kind, bool) { + val, ok := m.data[key] + return val, reflect.TypeOf(val).Kind(), ok +} + +func (m *MockExecutionContext) AsString(key string) (string, error) { + if val, ok := m.data[key]; ok { + if str, ok := val.(string); ok { + return str, nil + } + } + return "", nil +} + +func (m *MockExecutionContext) AsInt(key string) (int64, error) { + if val, ok := m.data[key]; ok { + if i, ok := val.(int64); ok { + return i, nil + } + } + return 0, nil +} + +func (m *MockExecutionContext) AsFloat(key string) (float64, error) { + if val, ok := m.data[key]; ok { + if f, ok := val.(float64); ok { + return f, nil + } + } + return 0, nil +} + +func (m *MockExecutionContext) AsBool(key string) (bool, error) { + if val, ok := m.data[key]; ok { + if b, ok := val.(bool); ok { + return b, nil + } + } + return false, nil +} + +func (m *MockExecutionContext) AsStringSlice(key string) ([]string, error) { + if val, ok := m.data[key]; ok { + if slice, ok := val.([]string); ok { + return slice, nil + } + } + return nil, nil +} + +func (m *MockExecutionContext) AsIntSlice(key string) ([]int, error) { + if val, ok := m.data[key]; ok { + if slice, ok := val.([]int); ok { + return slice, nil + } + } + return nil, nil +} + +func (m *MockExecutionContext) AsMap(key string) (map[string]any, error) { + if val, ok := m.data[key]; ok { + if m, ok := val.(map[string]any); ok { + return m, nil + } + } + return nil, nil +} + +// Implement other required interfaces (stubs for testing) +func (m *MockExecutionContext) GetAllManifests() []manifests.Manifest { return nil } + +func (m *MockExecutionContext) GetManifestsByKind(kind string) ([]manifests.Manifest, error) { + return nil, nil +} + +func (m *MockExecutionContext) GetManifestByID(id string) (manifests.Manifest, error) { + return nil, nil +} +func (m *MockExecutionContext) Channel(key string) chan any { return nil } +func (m *MockExecutionContext) ChannelT(key string, kind reflect.Kind) chan any { return nil } +func (m *MockExecutionContext) SafeSend(key string, val any) {} +func (m *MockExecutionContext) SendOutput(msg any) {} +func (m *MockExecutionContext) GetOutput() interfaces.Output { return nil } +func (m *MockExecutionContext) SetOutput(out interfaces.Output) {} + +func TestRunner_Apply(t *testing.T) { + runner := NewRunner() + ctx := NewMockExecutionContext() + ctx.Set("username", "john") + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple template", + input: "Hello {{ username }}", + expected: "Hello john", + }, + { + name: "no template", + input: "Hello world", + expected: "Hello world", + }, + { + name: "multiple templates", + input: "{{ username }} says hello to {{ username }}", + expected: "john says hello to john", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runner.Apply(ctx, tt.input, nil) + if result != tt.expected { + t.Errorf("Apply() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestRunner_ApplyBody(t *testing.T) { + runner := NewRunner() + ctx := NewMockExecutionContext() + ctx.Set("username", "john") + + tests := []struct { + name string + input map[string]any + expected map[string]any + }{ + { + name: "simple body", + input: map[string]any{ + "name": "{{ username }}", + "age": 25, + }, + expected: map[string]any{ + "name": "john", + "age": 25, + }, + }, + { + name: "nested body", + input: map[string]any{ + "user": map[string]any{ + "name": "{{ username }}", + "details": map[string]any{ + "active": true, + }, + }, + }, + expected: map[string]any{ + "user": map[string]any{ + "name": "john", + "details": map[string]any{ + "active": true, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := runner.ApplyBody(ctx, tt.input, nil) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("ApplyBody() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestRunner_MapHeaders(t *testing.T) { + runner := NewRunner() + ctx := NewMockExecutionContext() + ctx.Set("token", "abc123") + + input := map[string]string{ + "Authorization": "Bearer {{ token }}", + "Content-Type": "application/json", + } + + expected := map[string]string{ + "Authorization": "Bearer abc123", + "Content-Type": "application/json", + } + + result := runner.MapHeaders(ctx, input, nil) + if !reflect.DeepEqual(result, expected) { + t.Errorf("MapHeaders() = %v, want %v", result, expected) + } +} diff --git a/internal/core/runner/form/template_resolver.go b/internal/core/runner/form/template_resolver.go new file mode 100644 index 0000000..a25a243 --- /dev/null +++ b/internal/core/runner/form/template_resolver.go @@ -0,0 +1,130 @@ +package form + +import ( + "fmt" + "strings" + "unicode" + + "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" +) + +// DefaultTemplateResolver implements TemplateResolver interface +type DefaultTemplateResolver struct { + templateEngine *templates.TemplateEngine + valueExtractor ValueExtractor +} + +func NewDefaultTemplateResolver(templateEngine *templates.TemplateEngine, valueExtractor ValueExtractor) *DefaultTemplateResolver { + return &DefaultTemplateResolver{ + templateEngine: templateEngine, + valueExtractor: valueExtractor, + } +} + +func (r *DefaultTemplateResolver) Resolve(ctx interfaces.ExecutionContext, templateStr string, _ []*tests.Pass, processedData map[string]any, indexStack []int) (any, error) { + templateStr = strings.TrimSpace(templateStr) + content := templateStr + // If input was wrapped in {{ }}, remove them for processing + if strings.HasPrefix(templateStr, "{{") && strings.HasSuffix(templateStr, "}}") { + content = strings.Trim(templateStr, "{} \t") + } + + // Try to get value from context first + if val, ok := ctx.Get(content); ok { + return val, nil + } + + // Handle Body references + if strings.HasPrefix(content, "Body.") { + val, err := r.resolveBodyReference(content, processedData, indexStack) + return val, err + } + + // Handle Fake.* templates: always wrap in {{ ... }} for template engine + if strings.HasPrefix(content, "Fake.") { + wrapped := "{{ " + content + " }}" + result, err := r.templateEngine.Execute(wrapped) + if err != nil { + return wrapped, err + } + return result, nil + } + + // Handle Regex.* templates: always wrap in {{ ... }} for template engine + if strings.HasPrefix(content, "Regex(") { + wrapped := "{{ " + content + " }}" + result, err := r.templateEngine.Execute(wrapped) + if err != nil { + return wrapped, err + } + return result, nil + } + + // Use template engine for other cases + result, err := r.templateEngine.Execute(templateStr) + if err != nil { + return templateStr, err + } + return result, nil +} + +func (r *DefaultTemplateResolver) resolveBodyReference(content string, processedData map[string]any, indexStack []int) (any, error) { + expr := strings.TrimPrefix(content, "Body.") + pathParts, funcsPart := r.splitPathAndFuncs(expr) + + // Get value by path with index support + val, exists := r.valueExtractor.Extract(pathParts, processedData, indexStack) + if !exists { + return fmt.Sprintf("{{Body.%s}}", expr), nil + } + + // Apply functions if present + if funcsPart != "" { + finalTemplate := fmt.Sprintf("{{ %s%s }}", val, funcsPart) + result, err := r.templateEngine.Execute(finalTemplate) + if err != nil { + return val, err + } + return result, nil + } + + return val, nil +} + +// splitPathAndFuncs separates path parts from function calls +// +// Example: "users.#.name.ToUpper().Substring(1,3)" +// +// Returns: pathParts = ["users", "#", "name"], funcsPart = ".ToUpper().Substring(1,3)" +func (r *DefaultTemplateResolver) splitPathAndFuncs(expr string) (pathParts []string, funcsPart string) { + parts := strings.Split(expr, ".") + + idxFuncStart := len(parts) + for i, part := range parts { + if r.isFuncPart(part) { + idxFuncStart = i + break + } + } + + pathParts = parts[:idxFuncStart] + if idxFuncStart < len(parts) { + funcsPart = "." + strings.Join(parts[idxFuncStart:], ".") + } + + return pathParts, funcsPart +} + +func (r *DefaultTemplateResolver) isFuncPart(part string) bool { + // Check if part contains function call + if strings.Contains(part, "(") && strings.Contains(part, ")") { + return true + } + // Check if part starts with uppercase letter (method name) + if len(part) > 0 && unicode.IsUpper(rune(part[0])) { + return true + } + return false +} diff --git a/internal/core/runner/form/value_extractor.go b/internal/core/runner/form/value_extractor.go new file mode 100644 index 0000000..94ddec9 --- /dev/null +++ b/internal/core/runner/form/value_extractor.go @@ -0,0 +1,59 @@ +package form + +import ( + "strconv" + "strings" +) + +// DefaultValueExtractor implements ValueExtractor interface +type DefaultValueExtractor struct{} + +func NewDefaultValueExtractor() *DefaultValueExtractor { + return &DefaultValueExtractor{} +} + +func (e *DefaultValueExtractor) Extract(parts []string, data any, indexStack []int) (any, bool) { + current := data + stackIndex := 0 + + for _, part := range parts { + part = strings.TrimSpace(part) + + switch val := current.(type) { + case map[string]any: + v, ok := val[part] + if !ok { + return nil, false + } + current = v + + case []any: + if part == "#" { + // Use index from stack + if stackIndex >= len(indexStack) { + return nil, false + } + idx := indexStack[stackIndex] + stackIndex++ + + if idx < 0 || idx >= len(val) { + return nil, false + } + current = val[idx] + } else { + // Direct array index + idx, err := strconv.Atoi(part) + if err != nil || idx < 0 || idx >= len(val) { + return nil, false + } + current = val[idx] + } + + default: + // If we still have parts to process but reached a non-container type + return nil, false + } + } + + return current, true +} diff --git a/internal/core/runner/interfaces/store.go b/internal/core/runner/interfaces/store.go index 758c56b..7c0848d 100644 --- a/internal/core/runner/interfaces/store.go +++ b/internal/core/runner/interfaces/store.go @@ -30,6 +30,8 @@ type DataStore interface { AsMap(key string) (map[string]any, error) } +type SaveStore interface{} + type PassStore interface { Channel(key string) chan any ChannelT(key string, kind reflect.Kind) chan any diff --git a/internal/core/runner/save/extractor.go b/internal/core/runner/save/extractor.go new file mode 100644 index 0000000..fc2b768 --- /dev/null +++ b/internal/core/runner/save/extractor.go @@ -0,0 +1,137 @@ +package save + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/manifests/kinds/tests" + "github.com/apiqube/cli/internal/core/runner/interfaces" + "github.com/tidwall/gjson" +) + +const ( + KeyPrefix = "Save" + ResultKeySuffix = "Result" +) + +type Extractor struct{} + +func NewExtractor() *Extractor { + return &Extractor{} +} + +func (e *Extractor) Extract(ctx interfaces.ExecutionContext, man manifests.Manifest, c tests.HttpCase, resp *http.Response, reqBody, respBody []byte, caseResult *interfaces.CaseResult) { + key := FormSaveKey(man.GetID(), c.Name, ResultKeySuffix) + + result := &Result{ + ManifestID: man.GetID(), + CaseName: c.Name, + Target: resp.Request.URL.String(), + Method: resp.Request.Method, + StatusCode: resp.StatusCode, + Duration: caseResult.Duration, + Request: &Entry{ + Headers: make(map[string]string), + Body: make(map[string]any), + }, + Response: &Entry{ + Headers: make(map[string]string), + Body: make(map[string]any), + }, + } + + defer func() { + var builder strings.Builder + + builder.WriteString("\nExtractor:") + builder.WriteString(fmt.Sprintf("\nID: %s\nCase: %s\nTarget: %s\n Status: %d", result.ManifestID, result.CaseName, result.Target, result.StatusCode)) + + reqData, _ := json.MarshalIndent(result.Request, "", " ") + builder.WriteString(fmt.Sprintf("\n\tRequest: %v", string(reqData))) + + resData, _ := json.MarshalIndent(result.Response, "", " ") + builder.WriteString(fmt.Sprintf("\n\tResponse: %v", string(resData))) + + ctx.GetOutput().Logf(interfaces.DebugLevel, builder.String()) + + ctx.Set(key, result) + }() + + if c.Save != nil { + if c.Save.Request != nil { + result.Request.Headers = e.extractHeaders(c.Save.Request.Headers, resp.Request.Header, result.Request.Headers) + result.Request.Body = e.extractBody(c.Save.Request.Body, reqBody, result.Response.Body) + } + + if c.Save.Response != nil { + result.Response.Headers = e.extractHeaders(c.Save.Response.Headers, resp.Header, result.Response.Headers) + result.Response.Body = e.extractBody(c.Save.Response.Body, respBody, result.Response.Body) + } + } +} + +func (e *Extractor) extractHeaders(list []string, origin http.Header, source map[string]string) map[string]string { + var value string + for _, l := range list { + if value = origin.Get(l); value == "" { + value = "NONE" + } + + source[l] = value + } + + return source +} + +func (e *Extractor) extractBody(mapList map[string]string, origin []byte, source map[string]any) map[string]any { + var value any + var once bool + + for key, path := range mapList { + if path == "*" && !once { + if err := json.Unmarshal(origin, &value); err != nil { + continue + } + once = true + } else { + result := gjson.GetBytes(origin, path) + if !result.Exists() { + continue + } + + value = e.convertJsonResult(result) + } + + if value != nil { + source[key] = value + } + } + + return source +} + +func (e *Extractor) convertJsonResult(result gjson.Result) any { + switch { + case result.IsArray(): + var arr []any + result.ForEach(func(_, val gjson.Result) bool { + arr = append(arr, e.convertJsonResult(val)) + return true + }) + return arr + + case result.IsObject(): + obj := make(map[string]any) + result.ForEach(func(k, val gjson.Result) bool { + obj[k.String()] = e.convertJsonResult(val) + return true + }) + return obj + + default: + return result.Value() // string, number, bool, null + } +} diff --git a/internal/core/runner/save/result.go b/internal/core/runner/save/result.go new file mode 100644 index 0000000..6a92f94 --- /dev/null +++ b/internal/core/runner/save/result.go @@ -0,0 +1,27 @@ +package save + +import ( + "fmt" + "time" +) + +type Result struct { + ManifestID string + CaseName string + Target string + Method string + Duration time.Duration + StatusCode int + + Request *Entry + Response *Entry +} + +type Entry struct { + Headers map[string]string + Body map[string]any +} + +func FormSaveKey(manifestID, caseName, suffix string) string { + return fmt.Sprintf("%s.%s.%s.%s", KeyPrefix, manifestID, caseName, suffix) +} diff --git a/internal/core/runner/templates/README.md b/internal/core/runner/templates/README.md new file mode 100644 index 0000000..93d04bf --- /dev/null +++ b/internal/core/runner/templates/README.md @@ -0,0 +1,131 @@ +# Template Engine + +A high-performance, extensible template engine for generating dynamic data in Go. Supports fake data, regex, method chains, argument passing, and custom generators. + +--- + +## Table of Contents +- [Overview](#overview) +- [Key Concepts](#key-concepts) +- [Supported Directives](#supported-directives) +- [Generators](#supported-directives) +- [Methods](#methods) +- [Syntax & Examples](#syntax--examples) +--- + +## Overview + +TemplateEngine is designed for fast, flexible, and modular template processing. It is used to generate test data, mock payloads, and dynamic content for HTTP/API testing and automation. + +- **Fake data**: Generate names, emails, numbers, addresses, etc. +- **Regex**: Generate strings matching a regular expression. +- **Method chains**: Transform generated values (e.g. `.ToUpper()`, `.Replace()`) +- **Arguments**: Pass arguments to generators via dot or parentheses syntax. +- **Extensible**: Register your own generators and methods. + +--- + +## Key Concepts + +- **Generator**: A function that produces a value (e.g. `Fake.name`, `Regex`). +- **Method**: A function that transforms a value (e.g. `ToUpper`, `Replace`). +- **Directive**: A template expression inside `{{ ... }}`. +- **Arguments**: Values passed to generators or methods. + +--- + +## Supported Directives + +### Fake Data Generators +- `Fake.name` +- `Fake.email` +- `Fake.password` +- `Fake.int.min.max` (e.g. `Fake.int.1.10`) +- `Fake.uint.min.max` (e.g. `Fake.uint.10.100`) +- `Fake.float.min.max` (e.g. `Fake.float.0.1.10.5`) +- `Fake.bool` +- `Fake.phone` +- `Fake.address` +- `Fake.company` +- `Fake.date` +- `Fake.uuid` +- `Fake.url` +- `Fake.color` +- `Fake.word` +- `Fake.sentence` +- `Fake.country` +- `Fake.city` + +### Regex Generator +`Regex()` — generates a string matching the given regex pattern + (e.g, `^[a-z]{5,10}@example\\.com$`, `Regex(^[a-z]{5,10}@[a-z]{5,10}\\.(com|net|org)$\)` ) + +### Body Reference +- `Body.field` — reference to a value in the generated body (for nested templates). + +--- + +## Methods +Methods can be chained to any generator result: +- `ToUpper()` - Formats the value to uppercase. +- `ToLower()` - Formats the value to lowercase. +- `TrimSpace()` - Removes leading and trailing whitespace. +- `Replace(old, new)` - Replaces occurrences of `old` with `new`. +- `PadLeft(width, char)` - Pads the value to the left with the specified character. +- `PadRight(width, char)` - Pads the value to the right with the specified character. +- `Substring(start, length)` - Extracts a substring from the value. +- `Capitalize()` - Capitalizes the first letter of the value. +- `Reverse()` - Reverses the value. +- `RandomCase()` - Randomly capitalizes the first letter of the value. +- `SnakeCase()` - Converts the value to snake case. +- `CamelCase()` - Converts the value to camel case. +- `Split(sep)` - Splits the value by the specified separator. +- `Join(sep)` - Joins the values with the specified separator. +- `Index(idx)` - Returns the value at the specified index. +- `Cut(start, end)` - Extract the specified range from the value. +- `ToString()` - Converts the value to a string. +- `ToInt()` - Convert the value to an integer. +- `ToUint()` - Convert the value to an unsigned integer. +- `ToFloat()` - Convert the value to a float. +- `ToBool()` - Convert the value to a boolean. +- `ToArray()` - Converts the value to an array. + +All method does not fail if the value does not match the expected type of the method has not valid arguments. +In case of an error, the method returns the original value. + +--- + +## Syntax & Examples + +### Basic Usage +``` +{{ Fake.name }} +{{ Fake.email }} +{{ Fake.int.1.100 }} +{{ Regex(\"^[a-z]{5,10}@example\\.com$\") }} +``` + +### Method Chains +``` +{{ Fake.name.ToUpper() }} +{{ Fake.email.Replace('@', '_at_') }} +{{ Fake.word.ToString().PadLeft(10, '-') }} +``` + +### Arguments +``` +{{ Fake.uint.10.100 }} +{{ Fake.float.1.5.2.4 }} +{{ Regex('foo[0-9]{3}') }} +``` + +### Nested Templates +``` +{ + "user": { + "name": "{{ Fake.name }}", + "email": "{{ Fake.email.ToLower() }}", + "age": "{{ Fake.uint.18.99 }}" + } +} +``` diff --git a/internal/core/runner/templates/engine.go b/internal/core/runner/templates/engine.go new file mode 100644 index 0000000..6961917 --- /dev/null +++ b/internal/core/runner/templates/engine.go @@ -0,0 +1,288 @@ +package templates + +import ( + "fmt" + "regexp" + "strings" + "sync" +) + +// Precompiled regex for template expressions +var templateExprRe = regexp.MustCompile(`\{\{\s*(.*?)\s*}}`) + +// TemplateEngine evaluates template expressions with generators and methods. +type TemplateEngine struct { + funcs map[string]TemplateFunc + methods map[string]MethodFunc + mu sync.RWMutex +} + +type ( + TemplateFunc func(args ...string) (any, error) + MethodFunc func(value any, args ...string) (any, error) +) + +func New() *TemplateEngine { + e := &TemplateEngine{ + funcs: make(map[string]TemplateFunc), + methods: make(map[string]MethodFunc), + } + // Register built-in generators + e.RegisterFunc("Fake.name", fakeName) + e.RegisterFunc("Fake.email", fakeEmail) + e.RegisterFunc("Fake.password", fakePassword) + e.RegisterFunc("Fake.int", fakeInt) + e.RegisterFunc("Fake.uint", fakeUint) + e.RegisterFunc("Fake.float", fakeFloat) + e.RegisterFunc("Fake.bool", fakeBool) + e.RegisterFunc("Fake.phone", fakePhone) + e.RegisterFunc("Fake.address", fakeAddress) + e.RegisterFunc("Fake.company", fakeCompany) + e.RegisterFunc("Fake.date", fakeDate) + e.RegisterFunc("Fake.uuid", fakeUUID) + e.RegisterFunc("Fake.url", fakeURL) + e.RegisterFunc("Fake.color", fakeColor) + e.RegisterFunc("Fake.word", fakeWord) + e.RegisterFunc("Fake.sentence", fakeSentence) + e.RegisterFunc("Fake.country", fakeCountry) + e.RegisterFunc("Fake.city", fakeCity) + e.RegisterFunc("Regex", regex) + // Register built-in methods + e.RegisterMethod("ToString", methodToString) + e.RegisterMethod("ToUpper", methodToUpper) + e.RegisterMethod("ToLower", methodToLower) + e.RegisterMethod("Trim", methodTrimSpace) + e.RegisterMethod("Replace", methodReplace) + e.RegisterMethod("PadLeft", methodPadLeft) + e.RegisterMethod("PadRight", methodPadRight) + e.RegisterMethod("Substring", methodSubstring) + e.RegisterMethod("Capitalize", methodCapitalize) + e.RegisterMethod("Reverse", methodReverse) + e.RegisterMethod("RandomCase", methodRandomCase) + e.RegisterMethod("SnakeCase", methodSnakeCase) + e.RegisterMethod("CamelCase", methodCamelCase) + e.RegisterMethod("Split", methodSplit) + e.RegisterMethod("Join", methodJoin) + e.RegisterMethod("Index", methodIndex) + e.RegisterMethod("Cut", methodCut) + e.RegisterMethod("ToInt", methodToInt) + e.RegisterMethod("ToUint", methodToUint) + e.RegisterMethod("ToFloat", methodToFloat) + e.RegisterMethod("ToBool", methodToBool) + e.RegisterMethod("ToArray", methodToArray) + return e +} + +func (e *TemplateEngine) RegisterFunc(name string, fn TemplateFunc) { + e.mu.Lock() + defer e.mu.Unlock() + e.funcs[name] = fn +} + +func (e *TemplateEngine) RegisterMethod(name string, fn MethodFunc) { + e.mu.Lock() + defer e.mu.Unlock() + e.methods[name] = fn +} + +// Execute replaces all {{ ... }} expressions with generated values. +func (e *TemplateEngine) Execute(template string) (any, error) { + if isPureDirective(template) { + return e.processDirective(extractDirective(template)) + } + var b strings.Builder + last := 0 + for _, m := range templateExprRe.FindAllStringSubmatchIndex(template, -1) { + b.WriteString(template[last:m[0]]) + inner := strings.TrimSpace(template[m[2]:m[3]]) + val, err := e.processDirective(inner) + if err != nil { + return nil, fmt.Errorf("template error: %v", err) + } + b.WriteString(fmt.Sprint(val)) + last = m[1] + } + b.WriteString(template[last:]) + return b.String(), nil +} + +// processDirective parses and evaluates a directive with optional methods. +func (e *TemplateEngine) processDirective(directive string) (any, error) { + genPart, methodsPart := splitGeneratorAndMethods(directive) + genName, genArgs := parseGeneratorNameAndArgs(genPart) + generator, ok := e.getGenerator(genName) + if !ok { + return nil, fmt.Errorf("unknown generator: %s", genName) + } + val, err := generator(genArgs...) + if err != nil { + return nil, err + } + for _, m := range parseMethods(methodsPart) { + method, ok := e.getMethod(m.name) + if !ok { + return nil, fmt.Errorf("unknown method: %s", m.name) + } + val, err = method(val, m.args...) + if err != nil { + return nil, err + } + } + return val, nil +} + +// splitGeneratorAndMethods splits directive into generator part and method chain part. +func splitGeneratorAndMethods(directive string) (string, string) { + if strings.HasPrefix(directive, "Regex(") { + paren := 0 + for i, r := range directive { + switch r { + case '(': + paren++ + case ')': + paren-- + } + if paren == 0 && r == ')' { + return directive[:i+1], directive[i+1:] + } + } + return directive, "" + } + paren := 0 + for i := 0; i < len(directive); i++ { + r := directive[i] + switch r { + case '(': + paren++ + case ')': + if paren > 0 { + paren-- + } + case '.': + if paren == 0 && i+1 < len(directive) { + next := directive[i+1] + if next >= 'A' && next <= 'Z' { + return directive[:i], directive[i:] + } + } + } + } + return directive, "" +} + +// parseGeneratorNameAndArgs parses generator name and arguments from the generator part. +func parseGeneratorNameAndArgs(genPart string) (string, []string) { + if strings.HasPrefix(genPart, "Fake.") || strings.HasPrefix(genPart, "Body.") { + parts := strings.Split(genPart, ".") + if len(parts) > 2 { + return strings.Join(parts[:2], "."), parts[2:] + } + return genPart, nil + } + if idx := strings.Index(genPart, "("); idx != -1 && strings.HasSuffix(genPart, ")") { + name := genPart[:idx] + argsStr := genPart[idx+1 : len(genPart)-1] + if len(argsStr) > 0 { + return name, splitArgs(argsStr) + } + return name, nil + } + return genPart, nil +} + +// splitArgs splits arguments by comma, respecting quotes. +func splitArgs(s string) []string { + var args []string + var cur strings.Builder + inQuotes := false + quoteChar := byte(0) + for i := 0; i < len(s); i++ { + c := s[i] + if (c == '\'' || c == '"') && (i == 0 || s[i-1] != '\\') { + if inQuotes && c == quoteChar { + inQuotes = false + } else if !inQuotes { + inQuotes = true + quoteChar = c + } + } + if c == ',' && !inQuotes { + args = append(args, cur.String()) + cur.Reset() + } else { + cur.WriteByte(c) + } + } + if cur.Len() > 0 { + args = append(args, cur.String()) + } + return args +} + +type methodCall struct { + name string + args []string +} + +// parseMethods parses method chain from a string (e.g. .ToUpper().Replace(' ','_')) +func parseMethods(s string) []methodCall { + var methods []methodCall + i := 0 + for i < len(s) { + if s[i] != '.' { + i++ + continue + } + j := i + 1 + for j < len(s) && ((s[j] >= 'A' && s[j] <= 'Z') || (s[j] >= 'a' && s[j] <= 'z')) { + j++ + } + name := s[i+1 : j] + var args []string + if j < len(s) && s[j] == '(' { + k := j + 1 + paren := 1 + for k < len(s) && paren > 0 { + switch s[k] { + case '(': + paren++ + case ')': + paren-- + } + k++ + } + if paren == 0 { + argStr := s[j+1 : k-1] + args = splitArgs(argStr) + j = k + } + } + methods = append(methods, methodCall{name, args}) + i = j + } + return methods +} + +func (e *TemplateEngine) getGenerator(name string) (TemplateFunc, bool) { + e.mu.RLock() + defer e.mu.RUnlock() + fn, ok := e.funcs[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 +} + +func isPureDirective(template string) bool { + trimmed := strings.TrimSpace(template) + return strings.HasPrefix(trimmed, "{{") && strings.HasSuffix(trimmed, "}}") && + strings.Count(trimmed, "{{") == 1 && strings.Count(trimmed, "}}") == 1 +} + +func extractDirective(template string) string { + return strings.TrimSpace(template[2 : len(template)-2]) +} diff --git a/internal/core/runner/templates/engine_test.go b/internal/core/runner/templates/engine_test.go new file mode 100644 index 0000000..e07cb66 --- /dev/null +++ b/internal/core/runner/templates/engine_test.go @@ -0,0 +1,187 @@ +package templates + +import ( + "fmt" + "regexp" + "strings" + "testing" +) + +// --- GENERATORS --- +func TestTemplateEngine_FakeGenerators(t *testing.T) { + e := New() + cases := []struct { + template string + pattern string + name string + }{ + {"{{ Fake.name }}", `^[A-Za-z .'-]+$`, "Fake.name"}, + {"{{ Fake.email }}", `^[^@]+@[^@]+\.[a-z]+$`, "Fake.email"}, + {"{{ Fake.int.1.10 }}", `^(10|[1-9])$`, "Fake.int.1.10"}, + {"{{ Fake.uint.10.100 }}", `^\d{2,3}$`, "Fake.uint.10.100"}, + {"{{ Fake.bool }}", `^(true|false)$`, "Fake.bool"}, + {"{{ Fake.address }}", `.+`, "Fake.address"}, + {"{{ Fake.company }}", `.+`, "Fake.company"}, + {"{{ Fake.date }}", `.+`, "Fake.date"}, + {"{{ Fake.uuid }}", `^[a-f0-9-]{36}$`, "Fake.uuid"}, + {"{{ Fake.url }}", `^https?://`, "Fake.url"}, + {"{{ Fake.color }}", `.+`, "Fake.color"}, + {"{{ Fake.word }}", `^[A-Za-z]+$`, "Fake.word"}, + {"{{ Fake.sentence }}", `.+`, "Fake.sentence"}, + {"{{ Fake.country }}", `.+`, "Fake.country"}, + {"{{ Fake.city }}", `.+`, "Fake.city"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res, err := e.Execute(c.template) + if err != nil { + t.Fatalf("%s: unexpected error: %v", c.template, err) + } + if !regexp.MustCompile(c.pattern).MatchString(fmt.Sprint(res)) { + t.Errorf("%s: result '%v' does not match pattern %s", c.template, res, c.pattern) + } + }) + } +} + +func TestTemplateEngine_RegexGenerator(t *testing.T) { + e := New() + cases := []struct { + template string + pattern string + }{ + {"{{ Regex('^foo[0-9]{3}$') }}", `^foo\d{3}$`}, + {"{{ Regex('bar[A-Z]{2,4}') }}", `^bar[A-Z]{2,4}$`}, + } + for _, c := range cases { + res, err := e.Execute(c.template) + if err != nil { + t.Fatalf("Regex: unexpected error: %v", err) + } + if !regexp.MustCompile(c.pattern).MatchString(fmt.Sprint(res)) { + t.Errorf("Regex: result '%v' does not match %s", res, c.pattern) + } + } +} + +// --- METHODS --- +func TestTemplateEngine_Methods(t *testing.T) { + e := New() + cases := []struct { + template string + check func(string) bool + name string + }{ + {"{{ Fake.name.ToUpper() }}", func(s string) bool { return s == strings.ToUpper(s) }, "ToUpper"}, + {"{{ Fake.email.Replace('@','_at_') }}", func(s string) bool { return strings.Contains(s, "_at_") }, "Replace"}, + {"{{ Fake.name.ToLower().Capitalize() }}", func(s string) bool { return len(s) > 0 && s[0] >= 'A' && s[0] <= 'Z' }, "Capitalize"}, + {"{{ Fake.word.Reverse() }}", func(s string) bool { return len(s) > 0 }, "Reverse"}, + {"{{ Fake.word.RandomCase() }}", func(s string) bool { return len(s) > 0 }, "RandomCase"}, + {"{{ Fake.word.SnakeCase() }}", func(s string) bool { return strings.Contains(s, "_") || len(s) > 0 }, "SnakeCase"}, + {"{{ Fake.word.CamelCase() }}", func(s string) bool { return len(s) > 0 }, "CamelCase"}, + {"{{ Fake.word.Split('a').Join('-') }}", func(s string) bool { return strings.Contains(s, "-") || len(s) > 0 }, "SplitJoin"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res, err := e.Execute(c.template) + if err != nil { + t.Fatalf("%s: unexpected error: %v", c.template, err) + } + if !c.check(fmt.Sprint(res)) { + t.Errorf("%s: check failed for result '%v'", c.template, res) + } + }) + } +} + +// --- ARGUMENTS & EDGE CASES --- +func TestTemplateEngine_ArgumentsAndEdgeCases(t *testing.T) { + e := New() + cases := []struct { + template string + pattern string + name string + }{ + {"{{ Fake.uint.10.20 }}", `^(1[0-9]|20|10)$`, "Fake.uint.10.20"}, + {"{{ Fake.int.0.1 }}", `^(0|1)$`, "Fake.int.0.1"}, + {"{{ Fake.float.1.5.2.4 }}", `^[0-9]+\.[0-9]+$`, "Fake.float.1.5.2.4"}, + {"plain string", `^plain string$`, "plain"}, + {"{{ Fake.name }} and {{ Fake.email }}", `.+@.+`, "multi"}, + {"{{ Fake.int.1.1 }}", `^1$`, "Fake.int.1.1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + res, err := e.Execute(c.template) + if err != nil { + t.Fatalf("%s: unexpected error: %s", c.template, err.Error()) + } + if !regexp.MustCompile(c.pattern).MatchString(fmt.Sprint(res)) { + t.Errorf("%s: result '%v' does not match pattern %s", c.template, res, c.pattern) + } + }) + } +} + +// --- NESTED & COMPLEX --- +func TestTemplateEngine_NestedTemplates(t *testing.T) { + e := New() + template := `{"user":{"name":"{{ Fake.name }}","email":"{{ Fake.email.ToLower() }}","age":"{{ Fake.uint.18.99 }}"}}` + res, err := e.Execute(template) + if err != nil { + t.Fatalf("nested: unexpected error: %v", err) + } + if !strings.Contains(fmt.Sprint(res), "@") { + t.Errorf("nested: result '%v' missing email", res) + } +} + +// --- BODY REFERENCE (stub) --- +func TestTemplateEngine_BodyReference(t *testing.T) { + e := New() + // This is a stub: actual Body reference resolution depends on context, but we check parsing + res, err := e.Execute("{{ Body.someField }}") + if err == nil && res == "{{ Body.someField }}" { + t.Log("Body reference parsed as literal (expected in this context)") + } +} + +// --- ERROR HANDLING --- +func TestTemplateEngine_Errors(t *testing.T) { + e := New() + _, err := e.Execute("{{ Unknown.generator }}") + if err == nil { + t.Error("expected error for unknown generator") + } + _, err = e.Execute("{{ Fake.name.UnknownMethod() }}") + if err == nil { + t.Error("expected error for unknown method") + } + _, err = e.Execute("{{ Fake.int('notanint') }}") + if err == nil { + t.Error("expected error for invalid argument") + } +} + +// --- CUSTOM GENERATORS & METHODS --- +func TestTemplateEngine_Custom(t *testing.T) { + e := New() + e.RegisterFunc("Custom.hello", func(args ...string) (any, error) { + if len(args) > 0 { + return fmt.Sprintf("Hello, %s!", args[0]), nil + } + return "Hello, world!", nil + }) + + e.RegisterMethod("Exclaim", func(val any, args ...string) (any, error) { + return fmt.Sprintf("%v!!!", val), nil + }) + + res, err := e.Execute("{{ Custom.hello.Exclaim() }}") + if err != nil { + t.Fatalf("custom: unexpected error: %v", err) + } + + if !strings.HasSuffix(res.(string), "!!!") { + t.Errorf("custom: result '%v' missing exclamation", res) + } +} diff --git a/internal/core/runner/templates/fake.go b/internal/core/runner/templates/fake.go index 32e133b..42a4db8 100644 --- a/internal/core/runner/templates/fake.go +++ b/internal/core/runner/templates/fake.go @@ -65,7 +65,7 @@ func fakeInt(args ...string) (any, error) { } } - if minInt >= maxInt { + if minInt > maxInt { minInt, maxInt = maxInt, minInt-1 } @@ -93,7 +93,7 @@ func fakeUint(args ...string) (any, error) { minInt, maxInt = maxInt, minInt } - return gofakeit.UintRange(uint(minInt-1), uint(maxInt+1)), nil + return gofakeit.UintRange(uint(minInt), uint(maxInt)), nil } func fakeFloat(_ ...string) (any, error) { @@ -103,3 +103,47 @@ func fakeFloat(_ ...string) (any, error) { func fakeBool(_ ...string) (any, error) { return gofakeit.Bool(), nil } + +func fakePhone(_ ...string) (any, error) { + return gofakeit.Phone(), nil +} + +func fakeAddress(_ ...string) (any, error) { + return gofakeit.Address().Address, nil +} + +func fakeCompany(_ ...string) (any, error) { + return gofakeit.Company(), nil +} + +func fakeDate(_ ...string) (any, error) { + return gofakeit.Date(), nil +} + +func fakeUUID(_ ...string) (any, error) { + return gofakeit.UUID(), nil +} + +func fakeURL(_ ...string) (any, error) { + return gofakeit.URL(), nil +} + +func fakeColor(_ ...string) (any, error) { + return gofakeit.Color(), nil +} + +func fakeWord(_ ...string) (any, error) { + return gofakeit.Word(), nil +} + +func fakeSentence(_ ...string) (any, error) { + return gofakeit.SentenceSimple(), nil +} + +func fakeCountry(_ ...string) (any, error) { + return gofakeit.Country(), nil +} + +func fakeCity(_ ...string) (any, error) { + return gofakeit.City(), nil +} diff --git a/internal/core/runner/templates/genetator.go b/internal/core/runner/templates/genetator.go deleted file mode 100644 index ba5617b..0000000 --- a/internal/core/runner/templates/genetator.go +++ /dev/null @@ -1,172 +0,0 @@ -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 index 3e12e06..24f2226 100644 --- a/internal/core/runner/templates/methods.go +++ b/internal/core/runner/templates/methods.go @@ -2,9 +2,15 @@ package templates import ( "fmt" + "reflect" + "strconv" "strings" + + "golang.org/x/text/cases" + "golang.org/x/text/language" ) +func methodToString(value any, _ ...string) (any, error) { return fmt.Sprintf("%v", value), nil } func methodToUpper(value any, _ ...string) (any, error) { return strings.ToUpper(fmt.Sprintf("%v", value)), nil } @@ -13,11 +19,269 @@ 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" +func methodTrimSpace(value any, _ ...string) (any, error) { + return strings.TrimSpace(fmt.Sprint(value)), nil +} + +func methodReplace(value any, args ...string) (any, error) { + if len(args) < 2 { + return value, nil + } + + return strings.ReplaceAll(fmt.Sprintf("%v", value), clearArg(args[0]), clearArg(args[1])), nil +} + +func methodPadLeft(value any, args ...string) (any, error) { + if len(args) < 2 { + return value, nil + } + n := 0 + _, _ = fmt.Sscanf(args[0], "%d", &n) + s := fmt.Sprintf("%v", value) + for len(s) < n { + s = args[1] + s + } + return s, nil +} + +func methodPadRight(value any, args ...string) (any, error) { + if len(args) < 2 { + return value, nil + } + n := 0 + _, _ = fmt.Sscanf(args[0], "%d", &n) + s := fmt.Sprintf("%v", value) + for len(s) < n { + s = s + args[1] + } + return s, nil +} + +func methodSubstring(value any, args ...string) (any, error) { + s := fmt.Sprint(value) + start, end := 0, len(s) if len(args) > 0 { - cutset = args[0] + _, _ = fmt.Sscanf(args[0], "%d", &start) + } + if len(args) > 1 { + _, _ = fmt.Sscanf(args[1], "%d", &end) + } + if start < 0 { + start = 0 + } + if end > len(s) { + end = len(s) + } + if start > end { + start, end = end, start + } + return s[start:end], nil +} + +func methodCapitalize(value any, _ ...string) (any, error) { + s := fmt.Sprint(value) + if len(s) == 0 { + return s, nil + } + return strings.ToUpper(s[:1]) + s[1:], nil +} + +func methodReverse(value any, _ ...string) (any, error) { + s := fmt.Sprint(value) + r := []rune(s) + for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { + r[i], r[j] = r[j], r[i] + } + return string(r), nil +} + +func methodRandomCase(value any, _ ...string) (any, error) { + s := fmt.Sprint(value) + out := make([]rune, len(s)) + for i, c := range s { + if i%2 == 0 { + out[i] = []rune(strings.ToUpper(string(c)))[0] + } else { + out[i] = []rune(strings.ToLower(string(c)))[0] + } + } + return string(out), nil +} + +func methodSnakeCase(value any, _ ...string) (any, error) { + s := fmt.Sprint(value) + return strings.ReplaceAll(strings.ToLower(s), " ", "_"), nil +} + +func methodCamelCase(value any, _ ...string) (any, error) { + s := fmt.Sprint(value) + parts := strings.Fields(s) + for i := range parts { + if i == 0 { + parts[i] = strings.ToLower(parts[i]) + } else { + caser := cases.Title(language.English) + caser.String(parts[i]) + } + } + return strings.Join(parts, ""), nil +} + +func methodSplit(value any, args ...string) (any, error) { + if len(args) < 1 { + return value, nil + } + + return strings.Split(fmt.Sprint(value), clearArg(args[0])), nil +} + +func methodJoin(value any, args ...string) (any, error) { + if len(args) < 1 { + return value, nil + } + + if elems, ok := value.([]string); ok { + return strings.Join(elems, clearArg(args[0])), nil + } + + return value, nil +} + +func methodIndex(value any, args ...string) (any, error) { + if len(args) < 1 { + return value, nil + } + + if elems, ok := value.([]string); ok { + idx, err := strconv.Atoi(args[0]) + if err != nil || idx >= len(elems) || idx < 0 { + return value, nil + } + + return elems[idx], nil } - return strings.Trim(fmt.Sprintf("%v", value), cutset), nil + return value, nil +} + +func methodCut(value any, args ...string) (any, error) { + if len(args)%2 != 0 { + return value, nil + } + + if elems, ok := value.([]string); ok { + startStr := clearArg(args[0]) + endStr := clearArg(args[1]) + + start, err := strconv.Atoi(startStr) + if err != nil { + return value, err + } + + end, err := strconv.Atoi(endStr) + if err != nil { + return value, err + } + + if start < 0 || end < 0 { + start = 0 + end = 1 + } + + if start > end { + start, end = end, start + } + + if len(elems) < end { + return value, nil + } + + return elems[start:end], nil + } + + return value, nil +} + +func methodToInt(value any, _ ...string) (any, error) { + switch v := value.(type) { + case int: + return v, nil + case float64: + return int(v), nil + case string: + if i, err := strconv.Atoi(v); err == nil { + return i, nil + } + } + return value, fmt.Errorf("cannot convert %v to int", value) +} + +func methodToUint(value any, _ ...string) (any, error) { + switch v := value.(type) { + case uint: + return v, nil + case int: + if v < 0 { + return value, fmt.Errorf("cannot convert negative int %v to uint", v) + } + return uint(v), nil + case float64: + if v < 0 { + return value, fmt.Errorf("cannot convert negative float %v to uint", v) + } + return uint(v), nil + case string: + if u, err := strconv.ParseUint(v, 10, 64); err == nil { + return uint(u), nil + } + } + return value, fmt.Errorf("cannot convert %v to uint", value) +} + +func methodToFloat(value any, _ ...string) (any, error) { + switch v := value.(type) { + case float64: + return v, nil + case int: + return float64(v), nil + case uint: + return float64(v), nil + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f, nil + } + } + return value, fmt.Errorf("cannot convert %v to float64", value) +} + +func methodToBool(value any, _ ...string) (any, error) { + switch v := value.(type) { + case bool: + return v, nil + case string: + if b, err := strconv.ParseBool(v); err == nil { + return b, nil + } + case int: + return v != 0, nil + case float64: + return v != 0, nil + } + return value, fmt.Errorf("cannot convert %v to bool", value) +} + +func methodToArray(value any, _ ...string) (any, error) { + val := reflect.ValueOf(value) + if val.Kind() == reflect.Slice || val.Kind() == reflect.Array { + result := make([]any, val.Len()) + for i := 0; i < val.Len(); i++ { + result[i] = val.Index(i).Interface() + } + return result, nil + } + return value, fmt.Errorf("cannot convert %v to array", value) +} + +func clearArg(arg string) string { + return strings.TrimLeft(strings.TrimRight(arg, "'"), "'") } diff --git a/internal/core/runner/templates/regex.go b/internal/core/runner/templates/regex.go new file mode 100644 index 0000000..33f5212 --- /dev/null +++ b/internal/core/runner/templates/regex.go @@ -0,0 +1,22 @@ +package templates + +import ( + "fmt" + "strings" + + "github.com/brianvoe/gofakeit/v7" +) + +func regex(args ...string) (any, error) { + if len(args) == 0 { + return nil, fmt.Errorf("please provide a valid regex pattern") + } + + pattern := args[0] + pattern = strings.Trim(pattern, `"'`) + pattern = strings.ReplaceAll(pattern, `\\`, `\`) + pattern = strings.ReplaceAll(pattern, `\"`, `"`) + pattern = strings.ReplaceAll(pattern, `\'`, `'`) + + return gofakeit.Regex(pattern), nil +} diff --git a/internal/core/runner/values/extractor.go b/internal/core/runner/values/extractor.go deleted file mode 100644 index e2b228c..0000000 --- a/internal/core/runner/values/extractor.go +++ /dev/null @@ -1,58 +0,0 @@ -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, body []byte) { - 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(body, 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(body)) - } else { - if c.Save.Status { - ctx.SetTyped(saveKey("status"), resp.StatusCode, reflect.Int) - } else if c.Save.Body { - ctx.Set(saveKey("body"), string(body)) - } - } -} diff --git a/internal/core/store/db.go b/internal/core/store/db.go index da68b22..931f3d9 100644 --- a/internal/core/store/db.go +++ b/internal/core/store/db.go @@ -47,7 +47,11 @@ type Storage struct { } func NewStorage() (*Storage, error) { - path, err := xdg.DataFile(StorageDirPath) + return NewStorageWithPath(StorageDirPath) +} + +func NewStorageWithPath(path string) (*Storage, error) { + path, err := xdg.DataFile(path) if err != nil { return nil, fmt.Errorf("error getting data file path: %v", err) } diff --git a/internal/core/store/independ.go b/internal/core/store/independ.go index 65f406b..249c732 100644 --- a/internal/core/store/independ.go +++ b/internal/core/store/independ.go @@ -30,6 +30,19 @@ func Init() { }) } +func InitWithPath(path string) { + once.Do(func() { + db, err := NewStorageWithPath(path) + if err != nil { + cli.Errorf("Error initializing storage: %v", err) + } + + instance = db + enabled = true + initialized = true + }) +} + func Stop() { if instance != nil && initialized { enabled = false diff --git a/internal/validate/validator_test.go b/internal/validate/validator_test.go index 5eec015..6aa4aa0 100644 --- a/internal/validate/validator_test.go +++ b/internal/validate/validator_test.go @@ -292,16 +292,14 @@ var ( }, }, Save: &tests.Save{ - Json: map[string]string{"foo": "bar"}, - Headers: map[string]string{ - "Authorization": "Bearer token", + Request: &tests.SaveEntry{ + Body: map[string]string{"foo": "bar"}, + Headers: []string{ + "Authorization", + }, }, - Status: true, - Body: true, - All: true, - Group: "custom_save_group", }, - Pass: []tests.Pass{ + Pass: []*tests.Pass{ { From: "headers", Map: map[string]string{"Authorization": "Bearer token"}, diff --git a/tests/units/load_test.go b/tests/units/load_test.go new file mode 100644 index 0000000..74dd683 --- /dev/null +++ b/tests/units/load_test.go @@ -0,0 +1,52 @@ +package units + +import ( + "fmt" + "os" + "testing" + + "github.com/adrg/xdg" + "github.com/apiqube/cli/internal/core/io" + "github.com/apiqube/cli/internal/core/manifests" + "github.com/apiqube/cli/internal/core/store" + "github.com/stretchr/testify/require" +) + +const testDataPath = "testdata" + +func TestCoreIOLoad(t *testing.T) { + store.InitWithPath(testDataPath) + defer func() { + store.Stop() + + storePath, err := xdg.DataFile(testDataPath) + require.NoError(t, err) + + err = os.RemoveAll(storePath) + require.NoError(t, err) + }() + + t.Run("TestCoreIOLoad: nothing to load", func(t *testing.T) { + newMans, cachedMans, _ := io.LoadManifests(".") + + require.Len(t, newMans, 0) + require.Len(t, cachedMans, 0) + }) + + t.Run("TestCoreIOLoad: load server manifest", func(t *testing.T) { + newMans, cachedMans, err := io.LoadManifests(fmt.Sprintf("%s/test_server.yaml", testDataPath)) + + require.NoError(t, err) + require.Len(t, newMans, 1) + require.Len(t, cachedMans, 0) + require.Equal(t, manifests.ServerKind, newMans[0].GetKind()) + }) + + t.Run("TestCoreIOLoad: load several manifests", func(t *testing.T) { + newMans, cachedMans, err := io.LoadManifests(testDataPath) + + require.NoError(t, err) + require.Len(t, newMans, 2) + require.Len(t, cachedMans, 0) + }) +} diff --git a/tests/units/testdata/test_http_test.yaml b/tests/units/testdata/test_http_test.yaml new file mode 100644 index 0000000..f8ed7d8 --- /dev/null +++ b/tests/units/testdata/test_http_test.yaml @@ -0,0 +1,13 @@ +version: v1 +kind: HttpTest +metadata: + name: test_http_test +spec: + target: http://127.0.0.1:8081 + cases: + - name: Get All Users Test + method: GET + endpoint: /users + assert: + - target: status + equals: 200 \ No newline at end of file diff --git a/tests/units/testdata/test_server.yaml b/tests/units/testdata/test_server.yaml new file mode 100644 index 0000000..e808e34 --- /dev/null +++ b/tests/units/testdata/test_server.yaml @@ -0,0 +1,8 @@ +version: v1 +kind: Server +metadata: + name: test_server +spec: + baseUrl: "http://127.0.0.1:8081" + headers: + Content-Type: application/json \ No newline at end of file