|
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/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 |
|
| ... |