diff options
| -rw-r--r-- | README.md | 16 | ||||
| -rw-r--r-- | bad.yaml | 9 | ||||
| -rw-r--r-- | main.go | 102 |
3 files changed, 81 insertions, 46 deletions
@@ -1,4 +1,4 @@ -Hepi is a command-line tool for testing REST APIs using YAML-based configurations. It supports environment management, dynamic data generation, and response chaining, allowing you to build complex test scenarios where results from one request are used in subsequent ones. +Hepi is a command-line tool for testing REST APIs using YAML-based configurations. It supports environment management, dynamic data generation, and response chaining, allowing you to build complex test scenarios where results/state from one request is used in subsequent ones. ## Installation @@ -44,11 +44,11 @@ When resolving `{{variable}}` placeholders, Hepi follows a strict lookup sequenc 1. **System Environment**: Variables set in your shell or passed as command-line prefixes (e.g., `HOST=... go run ...`). 2. **Local `.env` File**: Variables loaded from a `.env` file in the current directory. These provide defaults that can be overridden by the system environment. 3. **YAML Environment**: Variables defined within the specific `environments` block selected via the `-env` flag. -4. **Persistent Results**: Key-value pairs stored in `.hepi.json` from previous request executions (accessed via `{{request_name.path.to.key}}`). +4. **Persistent State**: Key-value pairs stored in `.hepi.json` from previous request executions (accessed via `{{request_name.path.to.key}}`). #### Rationale -This hierarchy (System > .env > YAML > Results) is designed for **dynamic runtime overrides**: +This hierarchy (System > .env > YAML > State) is designed for **dynamic runtime overrides**: * **Non-destructive testing**: Override values from the CLI without modifying the static YAML configuration. * **Secret Management**: Keep sensitive credentials in the environment or `.env` files to avoid committing them to version control. * **CI/CD Integration**: Automated pipelines can inject configuration via environment variables which seamlessly take precedence. @@ -87,11 +87,11 @@ Hepi uses two types of placeholders: 1. **`{{variable}}`**: Used for substituting values from: * **Environment Variables**: Values from a `.env` file (loaded automatically if present) or system environment variables. * **Config Variables**: Variables defined in the `environments` section of the YAML. - * **Request Results**: Values captured from previous request responses (e.g., `{{login_req.token}}`). For arrays, use index notation (e.g., `{{setup_project.members.0.name}}`). + * **Request State**: Values captured from previous request responses (e.g., `{{login_req.token}}`). For arrays, use index notation (e.g., `{{setup_project.members.0.name}}`). 2. **`[[generator]]`**: Used for generating dynamic data (e.g., `[[email]]`, `[[name]]`). 3. **`[[oneof: a, b, c]]`**: Randomly selects one of the provided values. -### Result Chaining (Persistence) +### State Chaining (Persistence) When a request is executed, its response (if it's JSON) is stored in a local `.hepi.json` file. This allows subsequent requests to reference any field from the response using the `{{request_name.path.to.field}}` syntax. @@ -130,7 +130,7 @@ Hepi includes a wide range of generators for dynamic data. You can use these by *Refer to `generators.go` for the latest implementation of these functions.* -## Persistence File +## State File Hepi stores response data in `.hepi.json` in the current directory. This file is updated after every successful request that returns a JSON response. You can inspect this file or delete it to clear the "memory" of previous requests. @@ -197,7 +197,7 @@ To execute this request: hepi -env local -file test.yaml -req create_user ``` -### 3. Result Chaining (Persistence) +### 3. State Chaining (Persistence) This scenario shows a full authentication flow where the token from the login response is reused in a subsequent request. @@ -268,7 +268,7 @@ hepi -env local -file test.yaml -req search_items ### 5. Nested JSON, Arrays, and Header Subscriptions -Showing how to handle complex data structures and reuse specific nested fields from previous results. +Showing how to handle complex data structures and reuse specific nested fields from previous state. ```yaml environments: diff --git a/bad.yaml b/bad.yaml new file mode 100644 index 0000000..58ea037 --- /dev/null +++ b/bad.yaml @@ -0,0 +1,9 @@ +environments: + local: + host: http://localhost:8080 + +requests: + broken_req: + method: GET + url: {{host}}/api/test # This should fail + description: This request has unquoted braces @@ -59,36 +59,41 @@ type Runner struct { Config Config EnvName string Environment map[string]interface{} - Results map[string]interface{} + State map[string]interface{} HTTPClient *http.Client ShowHeaders bool + StateFile string } -const resultsFile = ".hepi.json" - func main() { godotenv.Load() - envName := flag.String("env", "", "Environment to use") - filePath := flag.String("file", "", "Path to the YAML file") + var envName string + flag.StringVar(&envName, "env", "", "Environment to use") + + var filePath string + flag.StringVar(&filePath, "file", "", "Path to the YAML file") + + var statePath string + flag.StringVar(&statePath, "state", ".hepi.json", "Path to state file") reqNames := flag.String("req", "", "Comma-separated list of request names to execute") groupName := flag.String("group", "", "Group to execute") showHeaders := flag.Bool("headers", false, "Display response headers") flag.Parse() - if *filePath == "" || *envName == "" { - fmt.Printf("Error: -file and -env are required\n\n") + if filePath == "" { + fmt.Printf("Error: -file is required\n\n") fmt.Printf("Usage: %s -env <environment> -file <file_path> [options]\n", os.Args[0]) os.Exit(1) } - runner, err := NewRunner(*filePath, *envName) + runner, err := NewRunner(filePath, envName, statePath) if err != nil { log.Fatalf("Error: %v", err) } runner.ShowHeaders = *showHeaders - if *reqNames == "" && *groupName == "" { + if envName == "" { runner.PrintHelp() return } @@ -107,7 +112,7 @@ func main() { } // NewRunner initializes a new Hepi runner. -func NewRunner(filePath, envName string) (*Runner, error) { +func NewRunner(filePath, envName, stateFile string) (*Runner, error) { data, err := os.ReadFile(filePath) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) @@ -124,25 +129,32 @@ func NewRunner(filePath, envName string) (*Runner, error) { selectedEnvName := envName var selectedEnv map[string]interface{} - found := false - for i := 0; i < len(config.Environments.Content); i += 2 { - if config.Environments.Content[i].Value == envName { - if err := config.Environments.Content[i+1].Decode(&selectedEnv); err != nil { - return nil, fmt.Errorf("failed to decode environment %q: %w", envName, err) + + if envName != "" { + found := false + var availableEnvs []string + for i := 0; i < len(config.Environments.Content); i += 2 { + name := config.Environments.Content[i].Value + availableEnvs = append(availableEnvs, name) + if name == envName { + if err := config.Environments.Content[i+1].Decode(&selectedEnv); err != nil { + return nil, fmt.Errorf("failed to decode environment %q: %w", envName, err) + } + found = true + break } - found = true - break } - } - if !found { - return nil, fmt.Errorf("environment %q not found", envName) + if !found { + return nil, fmt.Errorf("environment %q not found\nAvailable environments:\n- %s", envName, strings.Join(availableEnvs, "\n- ")) + } } return &Runner{ Config: config, EnvName: selectedEnvName, Environment: selectedEnv, - Results: loadResults(selectedEnvName), + State: loadState(selectedEnvName, stateFile), + StateFile: stateFile, HTTPClient: &http.Client{Timeout: 10 * time.Second}, }, nil } @@ -170,6 +182,9 @@ func (r *Runner) ExecuteRequests(reqNames string) error { filter[strings.TrimSpace(name)] = true } + // Validate that all requested requests exist + foundRequests := make(map[string]bool) + requestsNode := r.Config.Requests if requestsNode.Kind != yaml.MappingNode { return fmt.Errorf("requests must be a mapping") @@ -183,6 +198,7 @@ func (r *Runner) ExecuteRequests(reqNames string) error { if !filter[name] { continue } + foundRequests[name] = true var req Request if err := valNode.Decode(&req); err != nil { @@ -195,6 +211,16 @@ func (r *Runner) ExecuteRequests(reqNames string) error { } } + var missing []string + for req := range filter { + if !foundRequests[req] { + missing = append(missing, req) + } + } + if len(missing) > 0 { + return fmt.Errorf("requests not found: %s", strings.Join(missing, ", ")) + } + return nil } @@ -325,8 +351,8 @@ func (r *Runner) executeRequest(name string, req Request) error { var result interface{} if err := json.Unmarshal(respData, &result); err == nil { result = decodeRecursive(result) - r.Results[name] = result - saveResults(r.EnvName, r.Results) + r.State[name] = result + r.saveState() fmt.Printf("\n%sResponse:%s\n", colorBold, colorReset) var enc *jsoncolor.Encoder @@ -396,7 +422,7 @@ func (r *Runner) substitute(s string) string { // Priority 3: Previous Request Results parts := strings.Split(key, ".") if len(parts) > 1 { - if res, ok := r.Results[parts[0]]; ok { + if res, ok := r.State[parts[0]]; ok { return getValueFromMap(res, parts[1:]) } } @@ -478,37 +504,37 @@ func (r *Runner) PrintHelp() { fmt.Printf("\nUsage:\n %s -env <environment> -file <file_path> -req <request1,request2,...> -group <group_name> -headers\n", os.Args[0]) } -func loadResults(envName string) map[string]interface{} { - allResults := make(map[string]map[string]interface{}) - data, err := os.ReadFile(resultsFile) +func loadState(envName, stateFile string) map[string]interface{} { + allStates := make(map[string]map[string]interface{}) + data, err := os.ReadFile(stateFile) if err != nil { return make(map[string]interface{}) } - json.Unmarshal(data, &allResults) + json.Unmarshal(data, &allStates) - if res, ok := allResults[envName]; ok { + if res, ok := allStates[envName]; ok { return res } return make(map[string]interface{}) } -func saveResults(envName string, results map[string]interface{}) { - allResults := make(map[string]map[string]interface{}) - data, err := os.ReadFile(resultsFile) +func (r *Runner) saveState() { + allStates := make(map[string]map[string]interface{}) + data, err := os.ReadFile(r.StateFile) if err == nil { - json.Unmarshal(data, &allResults) + json.Unmarshal(data, &allStates) } - allResults[envName] = results + allStates[r.EnvName] = r.State - output, err := json.MarshalIndent(allResults, "", " ") + output, err := json.MarshalIndent(allStates, "", " ") if err != nil { - log.Printf("failed to marshal results: %v", err) + log.Printf("failed to marshal state: %v", err) return } - err = os.WriteFile(resultsFile, output, 0644) + err = os.WriteFile(r.StateFile, output, 0644) if err != nil { - log.Printf("failed to save results: %v", err) + log.Printf("failed to save state: %v", err) } } |
