Cli option cleanup

Author Mitja Felicijan <mitja.felicijan@gmail.com> 2026-02-05 03:04:53 +0100
Committer Mitja Felicijan <mitja.felicijan@gmail.com> 2026-02-05 03:04:53 +0100
Commit b36b9561c7000d987aadb7a0e9ab382ce683699a (patch)
-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
diff --git a/README.md b/README.md
1
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.
1
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.
2
  
2
  
3
## Installation
3
## Installation
4
  
4
  
...
44
1.  **System Environment**: Variables set in your shell or passed as command-line prefixes (e.g., `HOST=... go run ...`).
44
1.  **System Environment**: Variables set in your shell or passed as command-line prefixes (e.g., `HOST=... go run ...`).
45
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.
45
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.
46
3.  **YAML Environment**: Variables defined within the specific `environments` block selected via the `-env` flag.
46
3.  **YAML Environment**: Variables defined within the specific `environments` block selected via the `-env` flag.
47
4.  **Persistent Results**: Key-value pairs stored in `.hepi.json` from previous request executions (accessed via `{{request_name.path.to.key}}`).
47
4.  **Persistent State**: Key-value pairs stored in `.hepi.json` from previous request executions (accessed via `{{request_name.path.to.key}}`).
48
  
48
  
49
#### Rationale
49
#### Rationale
50
  
50
  
51
This hierarchy (System > .env > YAML > Results) is designed for **dynamic runtime overrides**:
51
This hierarchy (System > .env > YAML > State) is designed for **dynamic runtime overrides**:
52
*   **Non-destructive testing**: Override values from the CLI without modifying the static YAML configuration.
52
*   **Non-destructive testing**: Override values from the CLI without modifying the static YAML configuration.
53
*   **Secret Management**: Keep sensitive credentials in the environment or `.env` files to avoid committing them to version control.
53
*   **Secret Management**: Keep sensitive credentials in the environment or `.env` files to avoid committing them to version control.
54
*   **CI/CD Integration**: Automated pipelines can inject configuration via environment variables which seamlessly take precedence.
54
*   **CI/CD Integration**: Automated pipelines can inject configuration via environment variables which seamlessly take precedence.
...
87
1.  **`{{variable}}`**: Used for substituting values from:
87
1.  **`{{variable}}`**: Used for substituting values from:
88
    *   **Environment Variables**: Values from a `.env` file (loaded automatically if present) or system environment variables.
88
    *   **Environment Variables**: Values from a `.env` file (loaded automatically if present) or system environment variables.
89
    *   **Config Variables**: Variables defined in the `environments` section of the YAML.
89
    *   **Config Variables**: Variables defined in the `environments` section of the YAML.
90
    *   **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}}`).
90
    *   **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}}`).
91
2.  **`[[generator]]`**: Used for generating dynamic data (e.g., `[[email]]`, `[[name]]`).
91
2.  **`[[generator]]`**: Used for generating dynamic data (e.g., `[[email]]`, `[[name]]`).
92
3.  **`[[oneof: a, b, c]]`**: Randomly selects one of the provided values.
92
3.  **`[[oneof: a, b, c]]`**: Randomly selects one of the provided values.
93
  
93
  
94
### Result Chaining (Persistence)
94
### State Chaining (Persistence)
95
  
95
  
96
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.
96
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.
97
  
97
  
...
130
  
130
  
131
*Refer to `generators.go` for the latest implementation of these functions.*
131
*Refer to `generators.go` for the latest implementation of these functions.*
132
  
132
  
133
## Persistence File
133
## State File
134
  
134
  
135
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.
135
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.
136
  
136
  
...
197
hepi -env local -file test.yaml -req create_user
197
hepi -env local -file test.yaml -req create_user
198
```
198
```
199
  
199
  
200
### 3. Result Chaining (Persistence)
200
### 3. State Chaining (Persistence)
201
  
201
  
202
This scenario shows a full authentication flow where the token from the login response is reused in a subsequent request.
202
This scenario shows a full authentication flow where the token from the login response is reused in a subsequent request.
203
  
203
  
...
268
  
268
  
269
### 5. Nested JSON, Arrays, and Header Subscriptions
269
### 5. Nested JSON, Arrays, and Header Subscriptions
270
  
270
  
271
Showing how to handle complex data structures and reuse specific nested fields from previous results.
271
Showing how to handle complex data structures and reuse specific nested fields from previous state.
272
  
272
  
273
```yaml
273
```yaml
274
environments:
274
environments:
...
diff --git a/bad.yaml b/bad.yaml
  
1
environments:
  
2
  local:
  
3
    host: http://localhost:8080
  
4
  
  
5
requests:
  
6
  broken_req:
  
7
    method: GET
  
8
    url: {{host}}/api/test  # This should fail
  
9
    description: This request has unquoted braces
diff --git a/main.go b/main.go
...
59
	Config      Config
59
	Config      Config
60
	EnvName     string
60
	EnvName     string
61
	Environment map[string]interface{}
61
	Environment map[string]interface{}
62
	Results     map[string]interface{}
62
	State       map[string]interface{}
63
	HTTPClient  *http.Client
63
	HTTPClient  *http.Client
64
	ShowHeaders bool
64
	ShowHeaders bool
  
65
	StateFile   string
65
}
66
}
66
  
67
  
67
const resultsFile = ".hepi.json"
  
68
  
  
69
func main() {
68
func main() {
70
	godotenv.Load()
69
	godotenv.Load()
71
  
70
  
72
	envName := flag.String("env", "", "Environment to use")
71
	var envName string
73
	filePath := flag.String("file", "", "Path to the YAML file")
72
	flag.StringVar(&envName, "env", "", "Environment to use")
  
73
  
  
74
	var filePath string
  
75
	flag.StringVar(&filePath, "file", "", "Path to the YAML file")
  
76
  
  
77
	var statePath string
  
78
	flag.StringVar(&statePath, "state", ".hepi.json", "Path to state file")
74
	reqNames := flag.String("req", "", "Comma-separated list of request names to execute")
79
	reqNames := flag.String("req", "", "Comma-separated list of request names to execute")
75
	groupName := flag.String("group", "", "Group to execute")
80
	groupName := flag.String("group", "", "Group to execute")
76
	showHeaders := flag.Bool("headers", false, "Display response headers")
81
	showHeaders := flag.Bool("headers", false, "Display response headers")
77
	flag.Parse()
82
	flag.Parse()
78
  
83
  
79
	if *filePath == "" || *envName == "" {
84
	if filePath == "" {
80
		fmt.Printf("Error: -file and -env are required\n\n")
85
		fmt.Printf("Error: -file is required\n\n")
81
		fmt.Printf("Usage: %s -env <environment> -file <file_path> [options]\n", os.Args[0])
86
		fmt.Printf("Usage: %s -env <environment> -file <file_path> [options]\n", os.Args[0])
82
		os.Exit(1)
87
		os.Exit(1)
83
	}
88
	}
84
  
89
  
85
	runner, err := NewRunner(*filePath, *envName)
90
	runner, err := NewRunner(filePath, envName, statePath)
86
	if err != nil {
91
	if err != nil {
87
		log.Fatalf("Error: %v", err)
92
		log.Fatalf("Error: %v", err)
88
	}
93
	}
89
	runner.ShowHeaders = *showHeaders
94
	runner.ShowHeaders = *showHeaders
90
  
95
  
91
	if *reqNames == "" && *groupName == "" {
96
	if envName == "" {
92
		runner.PrintHelp()
97
		runner.PrintHelp()
93
		return
98
		return
94
	}
99
	}
...
107
}
112
}
108
  
113
  
109
// NewRunner initializes a new Hepi runner.
114
// NewRunner initializes a new Hepi runner.
110
func NewRunner(filePath, envName string) (*Runner, error) {
115
func NewRunner(filePath, envName, stateFile string) (*Runner, error) {
111
	data, err := os.ReadFile(filePath)
116
	data, err := os.ReadFile(filePath)
112
	if err != nil {
117
	if err != nil {
113
		return nil, fmt.Errorf("failed to read file: %w", err)
118
		return nil, fmt.Errorf("failed to read file: %w", err)
...
124
  
129
  
125
	selectedEnvName := envName
130
	selectedEnvName := envName
126
	var selectedEnv map[string]interface{}
131
	var selectedEnv map[string]interface{}
127
	found := false
132
  
128
	for i := 0; i < len(config.Environments.Content); i += 2 {
133
	if envName != "" {
129
		if config.Environments.Content[i].Value == envName {
134
		found := false
130
			if err := config.Environments.Content[i+1].Decode(&selectedEnv); err != nil {
135
		var availableEnvs []string
131
				return nil, fmt.Errorf("failed to decode environment %q: %w", envName, err)
136
		for i := 0; i < len(config.Environments.Content); i += 2 {
  
137
			name := config.Environments.Content[i].Value
  
138
			availableEnvs = append(availableEnvs, name)
  
139
			if name == envName {
  
140
				if err := config.Environments.Content[i+1].Decode(&selectedEnv); err != nil {
  
141
					return nil, fmt.Errorf("failed to decode environment %q: %w", envName, err)
  
142
				}
  
143
				found = true
  
144
				break
132
			}
145
			}
133
			found = true
  
134
			break
  
135
		}
146
		}
136
	}
147
		if !found {
137
	if !found {
148
			return nil, fmt.Errorf("environment %q not found\nAvailable environments:\n- %s", envName, strings.Join(availableEnvs, "\n- "))
138
		return nil, fmt.Errorf("environment %q not found", envName)
149
		}
139
	}
150
	}
140
  
151
  
141
	return &Runner{
152
	return &Runner{
142
		Config:      config,
153
		Config:      config,
143
		EnvName:     selectedEnvName,
154
		EnvName:     selectedEnvName,
144
		Environment: selectedEnv,
155
		Environment: selectedEnv,
145
		Results:     loadResults(selectedEnvName),
156
		State:       loadState(selectedEnvName, stateFile),
  
157
		StateFile:   stateFile,
146
		HTTPClient:  &http.Client{Timeout: 10 * time.Second},
158
		HTTPClient:  &http.Client{Timeout: 10 * time.Second},
147
	}, nil
159
	}, nil
148
}
160
}
...
170
		filter[strings.TrimSpace(name)] = true
182
		filter[strings.TrimSpace(name)] = true
171
	}
183
	}
172
  
184
  
  
185
	// Validate that all requested requests exist
  
186
	foundRequests := make(map[string]bool)
  
187
  
173
	requestsNode := r.Config.Requests
188
	requestsNode := r.Config.Requests
174
	if requestsNode.Kind != yaml.MappingNode {
189
	if requestsNode.Kind != yaml.MappingNode {
175
		return fmt.Errorf("requests must be a mapping")
190
		return fmt.Errorf("requests must be a mapping")
...
183
		if !filter[name] {
198
		if !filter[name] {
184
			continue
199
			continue
185
		}
200
		}
  
201
		foundRequests[name] = true
186
  
202
  
187
		var req Request
203
		var req Request
188
		if err := valNode.Decode(&req); err != nil {
204
		if err := valNode.Decode(&req); err != nil {
...
195
		}
211
		}
196
	}
212
	}
197
  
213
  
  
214
	var missing []string
  
215
	for req := range filter {
  
216
		if !foundRequests[req] {
  
217
			missing = append(missing, req)
  
218
		}
  
219
	}
  
220
	if len(missing) > 0 {
  
221
		return fmt.Errorf("requests not found: %s", strings.Join(missing, ", "))
  
222
	}
  
223
  
198
	return nil
224
	return nil
199
}
225
}
200
  
226
  
...
325
		var result interface{}
351
		var result interface{}
326
		if err := json.Unmarshal(respData, &result); err == nil {
352
		if err := json.Unmarshal(respData, &result); err == nil {
327
			result = decodeRecursive(result)
353
			result = decodeRecursive(result)
328
			r.Results[name] = result
354
			r.State[name] = result
329
			saveResults(r.EnvName, r.Results)
355
			r.saveState()
330
			fmt.Printf("\n%sResponse:%s\n", colorBold, colorReset)
356
			fmt.Printf("\n%sResponse:%s\n", colorBold, colorReset)
331
  
357
  
332
			var enc *jsoncolor.Encoder
358
			var enc *jsoncolor.Encoder
...
396
		// Priority 3: Previous Request Results
422
		// Priority 3: Previous Request Results
397
		parts := strings.Split(key, ".")
423
		parts := strings.Split(key, ".")
398
		if len(parts) > 1 {
424
		if len(parts) > 1 {
399
			if res, ok := r.Results[parts[0]]; ok {
425
			if res, ok := r.State[parts[0]]; ok {
400
				return getValueFromMap(res, parts[1:])
426
				return getValueFromMap(res, parts[1:])
401
			}
427
			}
402
		}
428
		}
...
478
	fmt.Printf("\nUsage:\n  %s -env <environment> -file <file_path> -req <request1,request2,...> -group <group_name> -headers\n", os.Args[0])
504
	fmt.Printf("\nUsage:\n  %s -env <environment> -file <file_path> -req <request1,request2,...> -group <group_name> -headers\n", os.Args[0])
479
}
505
}
480
  
506
  
481
func loadResults(envName string) map[string]interface{} {
507
func loadState(envName, stateFile string) map[string]interface{} {
482
	allResults := make(map[string]map[string]interface{})
508
	allStates := make(map[string]map[string]interface{})
483
	data, err := os.ReadFile(resultsFile)
509
	data, err := os.ReadFile(stateFile)
484
	if err != nil {
510
	if err != nil {
485
		return make(map[string]interface{})
511
		return make(map[string]interface{})
486
	}
512
	}
487
	json.Unmarshal(data, &allResults)
513
	json.Unmarshal(data, &allStates)
488
  
514
  
489
	if res, ok := allResults[envName]; ok {
515
	if res, ok := allStates[envName]; ok {
490
		return res
516
		return res
491
	}
517
	}
492
	return make(map[string]interface{})
518
	return make(map[string]interface{})
493
}
519
}
494
  
520
  
495
func saveResults(envName string, results map[string]interface{}) {
521
func (r *Runner) saveState() {
496
	allResults := make(map[string]map[string]interface{})
522
	allStates := make(map[string]map[string]interface{})
497
	data, err := os.ReadFile(resultsFile)
523
	data, err := os.ReadFile(r.StateFile)
498
	if err == nil {
524
	if err == nil {
499
		json.Unmarshal(data, &allResults)
525
		json.Unmarshal(data, &allStates)
500
	}
526
	}
501
  
527
  
502
	allResults[envName] = results
528
	allStates[r.EnvName] = r.State
503
  
529
  
504
	output, err := json.MarshalIndent(allResults, "", "  ")
530
	output, err := json.MarshalIndent(allStates, "", "  ")
505
	if err != nil {
531
	if err != nil {
506
		log.Printf("failed to marshal results: %v", err)
532
		log.Printf("failed to marshal state: %v", err)
507
		return
533
		return
508
	}
534
	}
509
	err = os.WriteFile(resultsFile, output, 0644)
535
	err = os.WriteFile(r.StateFile, output, 0644)
510
	if err != nil {
536
	if err != nil {
511
		log.Printf("failed to save results: %v", err)
537
		log.Printf("failed to save state: %v", err)
512
	}
538
	}
513
}
539
}
514
  
540
  
...