summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMitja Felicijan <mitja.felicijan@gmail.com>2026-02-05 03:04:53 +0100
committerMitja Felicijan <mitja.felicijan@gmail.com>2026-02-05 03:04:53 +0100
commitb36b9561c7000d987aadb7a0e9ab382ce683699a (patch)
tree4d9b291558bde53a750216093f25d1e06bc1d393
parent6960aecc25400320adee1b8802a86839326e15b6 (diff)
downloadhepi-b36b9561c7000d987aadb7a0e9ab382ce683699a.tar.gz
Cli option cleanup
-rw-r--r--README.md16
-rw-r--r--bad.yaml9
-rw-r--r--main.go102
3 files changed, 81 insertions, 46 deletions
diff --git a/README.md b/README.md
index 0a3ef98..64e85c2 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/main.go b/main.go
index b2f6cbb..8bba841 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
}