Initial commit
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// ChainedVars holds auto-extracted fields from the most-recent response.
|
||||
// Keys are formatted as "last_FIELD" (top-level) or "last_PARENT_FIELD"
|
||||
// (one level of nesting). Use them in any request as {{last_id}}, etc.
|
||||
//
|
||||
// The map is replaced entirely on each successful response so stale values
|
||||
// from a previous call do not bleed into unrelated requests.
|
||||
var ChainedVars = make(map[string]string)
|
||||
|
||||
// ExtractChained parses body as a JSON object and populates ChainedVars.
|
||||
// Non-JSON bodies are silently ignored.
|
||||
func ExtractChained(body []byte) {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return
|
||||
}
|
||||
next := make(map[string]string)
|
||||
flattenJSON(next, "last", data, 0)
|
||||
ChainedVars = next
|
||||
}
|
||||
|
||||
// flattenJSON recursively walks obj up to maxDepth levels deep.
|
||||
func flattenJSON(dst map[string]string, prefix string, obj map[string]interface{}, depth int) {
|
||||
if depth > 1 {
|
||||
return
|
||||
}
|
||||
for k, v := range obj {
|
||||
key := prefix + "_" + k
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
dst[key] = val
|
||||
case float64:
|
||||
if val == float64(int64(val)) {
|
||||
dst[key] = fmt.Sprintf("%d", int64(val))
|
||||
} else {
|
||||
dst[key] = fmt.Sprintf("%g", val)
|
||||
}
|
||||
case bool:
|
||||
if val {
|
||||
dst[key] = "true"
|
||||
} else {
|
||||
dst[key] = "false"
|
||||
}
|
||||
case map[string]interface{}:
|
||||
flattenJSON(dst, key, val, depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"greq/internal/storage"
|
||||
)
|
||||
|
||||
// SavedToken is the most-recently captured bearer token.
|
||||
// It is populated automatically from any response that contains a
|
||||
// top-level "token", "access_token", "accessToken", "jwt", or "id_token" field.
|
||||
var SavedToken string
|
||||
|
||||
// Response holds the parsed result of an HTTP call.
|
||||
type Response struct {
|
||||
StatusCode int
|
||||
Status string
|
||||
Body string // pretty-printed if JSON, raw otherwise
|
||||
Headers http.Header
|
||||
IsJSON bool
|
||||
Duration time.Duration
|
||||
Size int // raw body bytes
|
||||
}
|
||||
|
||||
// ResolveVariables replaces every {{key}} placeholder in input.
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. Values from env.yaml (envs map)
|
||||
// 2. The captured bearer token ({{token}} and {{access_token}})
|
||||
//
|
||||
// Missing keys are left as-is (e.g. "{{unknown}}" stays in the string)
|
||||
// so the caller can detect unresolved variables by scanning for "{{".
|
||||
// ResolveVariables replaces {{key}} placeholders in the following order:
|
||||
// 1. env.yaml values (envs)
|
||||
// 2. Auto-chained vars from the last response (ChainedVars, e.g. {{last_id}})
|
||||
// 3. Captured bearer token ({{token}}, {{access_token}})
|
||||
//
|
||||
// Unknown keys are left as-is so callers can detect them by scanning for "{{".
|
||||
func ResolveVariables(input string, envs map[string]string) string {
|
||||
for k, v := range envs {
|
||||
input = strings.ReplaceAll(input, "{{"+k+"}}", v)
|
||||
}
|
||||
for k, v := range ChainedVars {
|
||||
input = strings.ReplaceAll(input, "{{"+k+"}}", v)
|
||||
}
|
||||
if SavedToken != "" {
|
||||
input = strings.ReplaceAll(input, "{{token}}", SavedToken)
|
||||
input = strings.ReplaceAll(input, "{{access_token}}", SavedToken)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// Do executes the request described by rf with variable substitution applied.
|
||||
func Do(rf storage.RequestFile, envs map[string]string) (*Response, error) {
|
||||
finalURL := ResolveVariables(rf.URL, envs)
|
||||
finalBody := ResolveVariables(rf.Body, envs)
|
||||
|
||||
req, err := http.NewRequest(rf.Method, finalURL, bytes.NewBufferString(finalBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
// Headers from request file (variables resolved)
|
||||
for k, v := range rf.Headers {
|
||||
req.Header.Set(k, ResolveVariables(v, envs))
|
||||
}
|
||||
|
||||
// Auto-inject token when present and header not already set
|
||||
if SavedToken != "" && req.Header.Get("Authorization") == "" {
|
||||
req.Header.Set("Authorization", "Bearer "+SavedToken)
|
||||
}
|
||||
|
||||
// Default Content-Type for non-empty bodies
|
||||
if finalBody != "" && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
|
||||
// Extract token and chain variables from the response body
|
||||
extractToken(raw)
|
||||
ExtractChained(raw)
|
||||
|
||||
// Pretty-print JSON bodies
|
||||
body := string(raw)
|
||||
isJSON := false
|
||||
var pretty bytes.Buffer
|
||||
if json.Indent(&pretty, raw, "", " ") == nil && pretty.Len() > 0 {
|
||||
body = pretty.String()
|
||||
isJSON = true
|
||||
}
|
||||
|
||||
return &Response{
|
||||
StatusCode: resp.StatusCode,
|
||||
Status: resp.Status,
|
||||
Body: body,
|
||||
Headers: resp.Header,
|
||||
IsJSON: isJSON,
|
||||
Duration: elapsed,
|
||||
Size: len(raw),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// extractToken scans the top level of a JSON object for common token field names
|
||||
// and stores the first match in SavedToken.
|
||||
func extractToken(body []byte) {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"token", "access_token", "accessToken", "jwt", "id_token"} {
|
||||
if v, ok := data[key]; ok {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
SavedToken = s
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"greq/internal/storage"
|
||||
)
|
||||
|
||||
// ImportCURL parses a curl command string into a RequestFile.
|
||||
// Handles -X, -H/--header, -d/--data/--data-raw, -u/--user, -b/--cookie.
|
||||
// Unknown placeholder variables ({{…}}) are left as-is.
|
||||
func ImportCURL(raw string) (storage.RequestFile, error) {
|
||||
tokens := tokenizeCURL(raw)
|
||||
if len(tokens) == 0 {
|
||||
return storage.RequestFile{}, fmt.Errorf("empty input")
|
||||
}
|
||||
if !strings.EqualFold(tokens[0], "curl") {
|
||||
return storage.RequestFile{}, fmt.Errorf("does not start with 'curl'")
|
||||
}
|
||||
|
||||
rf := storage.RequestFile{
|
||||
Method: "GET",
|
||||
Headers: make(map[string]string),
|
||||
}
|
||||
|
||||
i := 1
|
||||
for i < len(tokens) {
|
||||
tok := tokens[i]
|
||||
switch tok {
|
||||
case "-X", "--request":
|
||||
i++
|
||||
if i < len(tokens) {
|
||||
rf.Method = strings.ToUpper(tokens[i])
|
||||
}
|
||||
case "-H", "--header":
|
||||
i++
|
||||
if i < len(tokens) {
|
||||
if k, v, ok := splitHeader(tokens[i]); ok {
|
||||
rf.Headers[k] = v
|
||||
}
|
||||
}
|
||||
case "-d", "--data", "--data-raw", "--data-ascii", "--data-urlencode":
|
||||
i++
|
||||
if i < len(tokens) {
|
||||
if rf.Body != "" {
|
||||
rf.Body += "&"
|
||||
}
|
||||
rf.Body += tokens[i]
|
||||
if rf.Method == "GET" {
|
||||
rf.Method = "POST"
|
||||
}
|
||||
}
|
||||
case "--data-binary":
|
||||
i++
|
||||
if i < len(tokens) && !strings.HasPrefix(tokens[i], "@") {
|
||||
rf.Body = tokens[i]
|
||||
if rf.Method == "GET" {
|
||||
rf.Method = "POST"
|
||||
}
|
||||
}
|
||||
case "-u", "--user":
|
||||
i++
|
||||
if i < len(tokens) {
|
||||
enc := base64.StdEncoding.EncodeToString([]byte(tokens[i]))
|
||||
rf.Headers["Authorization"] = "Basic " + enc
|
||||
}
|
||||
case "-b", "--cookie":
|
||||
i++
|
||||
if i < len(tokens) {
|
||||
rf.Headers["Cookie"] = tokens[i]
|
||||
}
|
||||
case "-A", "--user-agent":
|
||||
i++
|
||||
if i < len(tokens) {
|
||||
rf.Headers["User-Agent"] = tokens[i]
|
||||
}
|
||||
// Flags to silently ignore (no value)
|
||||
case "--compressed", "--silent", "-s", "--insecure", "-k",
|
||||
"-L", "--location", "-v", "--verbose", "-i", "--include",
|
||||
"--no-keepalive", "--http2", "--http1.1", "--http1.0":
|
||||
// Short flags with values that we don't handle — skip value
|
||||
default:
|
||||
if !strings.HasPrefix(tok, "-") {
|
||||
if rf.URL == "" {
|
||||
rf.URL = tok
|
||||
}
|
||||
} else if len(tok) == 2 {
|
||||
// Unknown short flag: skip its value
|
||||
i++
|
||||
}
|
||||
// Unknown long flags: skip
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
if rf.URL == "" {
|
||||
return rf, fmt.Errorf("no URL found in curl command")
|
||||
}
|
||||
if len(rf.Headers) == 0 {
|
||||
rf.Headers = nil
|
||||
}
|
||||
rf.Name = inferName(rf.Method, rf.URL)
|
||||
return rf, nil
|
||||
}
|
||||
|
||||
// ExportCURL builds a shell-safe curl command from a RequestFile.
|
||||
// Variables are resolved before embedding in the command string.
|
||||
func ExportCURL(rf storage.RequestFile, envs map[string]string) string {
|
||||
var parts []string
|
||||
parts = append(parts, "curl")
|
||||
parts = append(parts, shellQuote(ResolveVariables(rf.URL, envs)))
|
||||
|
||||
if rf.Method != "GET" || rf.Body != "" {
|
||||
parts = append(parts, "-X", rf.Method)
|
||||
}
|
||||
|
||||
// Deterministic header order
|
||||
keys := make([]string, 0, len(rf.Headers))
|
||||
for k := range rf.Headers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
v := ResolveVariables(rf.Headers[k], envs)
|
||||
parts = append(parts, "-H", shellQuote(k+": "+v))
|
||||
}
|
||||
|
||||
if rf.Body != "" {
|
||||
parts = append(parts, "--data-raw", shellQuote(ResolveVariables(rf.Body, envs)))
|
||||
}
|
||||
|
||||
return strings.Join(parts, " \\\n ")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func tokenizeCURL(s string) []string {
|
||||
// Join backslash-newline continuations
|
||||
s = strings.ReplaceAll(s, "\\\n", " ")
|
||||
s = strings.ReplaceAll(s, "\\\r\n", " ")
|
||||
s = strings.TrimSpace(s)
|
||||
|
||||
var tokens []string
|
||||
var cur strings.Builder
|
||||
inSingle := false
|
||||
inDouble := false
|
||||
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c == '\'' && !inDouble:
|
||||
inSingle = !inSingle
|
||||
case c == '"' && !inSingle:
|
||||
inDouble = !inDouble
|
||||
case c == '\\' && !inSingle:
|
||||
i++
|
||||
if i < len(s) {
|
||||
cur.WriteByte(s[i])
|
||||
}
|
||||
case (c == ' ' || c == '\t' || c == '\n') && !inSingle && !inDouble:
|
||||
if cur.Len() > 0 {
|
||||
tokens = append(tokens, cur.String())
|
||||
cur.Reset()
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
}
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
tokens = append(tokens, cur.String())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func splitHeader(s string) (key, val string, ok bool) {
|
||||
idx := strings.Index(s, ": ")
|
||||
if idx == -1 {
|
||||
idx = strings.Index(s, ":")
|
||||
if idx == -1 {
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s[:idx]), strings.TrimSpace(s[idx+1:]), true
|
||||
}
|
||||
|
||||
func inferName(method, rawURL string) string {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Path == "" || u.Path == "/" {
|
||||
return method + " Request"
|
||||
}
|
||||
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
last := parts[len(parts)-1]
|
||||
if last == "" || last == "{" {
|
||||
return method + " Request"
|
||||
}
|
||||
// Title-case the last path segment
|
||||
words := strings.FieldsFunc(strings.ReplaceAll(last, "_", " "), func(r rune) bool {
|
||||
return r == '-' || unicode.IsSpace(r)
|
||||
})
|
||||
for i, w := range words {
|
||||
if len(w) > 0 {
|
||||
words[i] = strings.ToUpper(w[:1]) + w[1:]
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// shellQuote wraps s in single quotes, escaping any embedded single quotes.
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
)
|
||||
|
||||
// TestResult holds the outcome of a single test assertion.
|
||||
type TestResult struct {
|
||||
Pass bool
|
||||
Message string
|
||||
}
|
||||
|
||||
// RunLuaTestScript executes a Lua script against a Response.
|
||||
//
|
||||
// Globals available in the script:
|
||||
//
|
||||
// status (number) HTTP status code, e.g. 200
|
||||
// status_text (string) Full status string, e.g. "200 OK"
|
||||
// body (string) Raw response body
|
||||
// headers (table) Response headers, lower-cased keys
|
||||
// json_body (table) Parsed JSON body, or nil if not JSON
|
||||
// duration_ms (number) Round-trip duration in milliseconds
|
||||
// size (number) Response body size in bytes
|
||||
//
|
||||
// Helper functions:
|
||||
//
|
||||
// pass(message) Record a passing assertion
|
||||
// fail(message) Record a failing assertion (does NOT abort the script)
|
||||
// assert_eq(a, b, msg) fail(msg) if a ~= b, else pass(msg)
|
||||
// assert_status(code) assert_eq(status, code, "status == "..code)
|
||||
// json_decode(str) Parse a JSON string → Lua table
|
||||
func RunLuaTestScript(script string, resp *Response) ([]TestResult, error) {
|
||||
if strings.TrimSpace(script) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
L := lua.NewState(lua.Options{SkipOpenLibs: false})
|
||||
defer L.Close()
|
||||
|
||||
var results []TestResult
|
||||
|
||||
// Inject pass / fail helpers
|
||||
L.SetGlobal("pass", L.NewFunction(func(L *lua.LState) int {
|
||||
msg := L.OptString(1, "")
|
||||
results = append(results, TestResult{Pass: true, Message: msg})
|
||||
return 0
|
||||
}))
|
||||
L.SetGlobal("fail", L.NewFunction(func(L *lua.LState) int {
|
||||
msg := L.OptString(1, "")
|
||||
results = append(results, TestResult{Pass: false, Message: msg})
|
||||
return 0
|
||||
}))
|
||||
L.SetGlobal("assert_eq", L.NewFunction(func(L *lua.LState) int {
|
||||
a := L.Get(1)
|
||||
b := L.Get(2)
|
||||
msg := L.OptString(3, fmt.Sprintf("%v == %v", a, b))
|
||||
if lua.LVCanConvToString(a) && lua.LVCanConvToString(b) &&
|
||||
lua.LVAsString(a) == lua.LVAsString(b) {
|
||||
results = append(results, TestResult{Pass: true, Message: msg})
|
||||
} else {
|
||||
results = append(results, TestResult{
|
||||
Pass: false,
|
||||
Message: fmt.Sprintf("%s (got %v, expected %v)", msg, a, b),
|
||||
})
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
L.SetGlobal("assert_status", L.NewFunction(func(L *lua.LState) int {
|
||||
expected := L.CheckInt(1)
|
||||
msg := fmt.Sprintf("status == %d", expected)
|
||||
if resp.StatusCode == expected {
|
||||
results = append(results, TestResult{Pass: true, Message: msg})
|
||||
} else {
|
||||
results = append(results, TestResult{
|
||||
Pass: false,
|
||||
Message: fmt.Sprintf("%s (got %d)", msg, resp.StatusCode),
|
||||
})
|
||||
}
|
||||
return 0
|
||||
}))
|
||||
|
||||
// json_decode helper
|
||||
L.SetGlobal("json_decode", L.NewFunction(func(L *lua.LState) int {
|
||||
s := L.CheckString(1)
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||
L.Push(lua.LNil)
|
||||
L.Push(lua.LString(err.Error()))
|
||||
return 2
|
||||
}
|
||||
L.Push(goToLua(L, v))
|
||||
return 1
|
||||
}))
|
||||
|
||||
// Set response globals
|
||||
L.SetGlobal("status", lua.LNumber(resp.StatusCode))
|
||||
L.SetGlobal("status_text", lua.LString(resp.Status))
|
||||
L.SetGlobal("body", lua.LString(resp.Body))
|
||||
L.SetGlobal("duration_ms", lua.LNumber(resp.Duration.Milliseconds()))
|
||||
L.SetGlobal("size", lua.LNumber(resp.Size))
|
||||
|
||||
// headers table (lower-cased keys)
|
||||
headersTbl := L.NewTable()
|
||||
for k, vals := range resp.Headers {
|
||||
if len(vals) > 0 {
|
||||
L.SetField(headersTbl, strings.ToLower(k), lua.LString(vals[0]))
|
||||
}
|
||||
}
|
||||
L.SetGlobal("headers", headersTbl)
|
||||
|
||||
// json_body: parse resp.Body if JSON
|
||||
var jsonBody interface{}
|
||||
if resp.IsJSON {
|
||||
if err := json.Unmarshal([]byte(resp.Body), &jsonBody); err == nil {
|
||||
L.SetGlobal("json_body", goToLua(L, jsonBody))
|
||||
} else {
|
||||
L.SetGlobal("json_body", lua.LNil)
|
||||
}
|
||||
} else {
|
||||
L.SetGlobal("json_body", lua.LNil)
|
||||
}
|
||||
|
||||
if err := L.DoString(script); err != nil {
|
||||
// Include any results collected before the error
|
||||
errResult := TestResult{
|
||||
Pass: false,
|
||||
Message: "Lua error: " + err.Error(),
|
||||
}
|
||||
results = append(results, errResult)
|
||||
return results, nil // return results, not the error, so UI can display them
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// goToLua converts a Go value (from json.Unmarshal) into a lua.LValue.
|
||||
func goToLua(L *lua.LState, v interface{}) lua.LValue {
|
||||
if v == nil {
|
||||
return lua.LNil
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
return lua.LBool(val)
|
||||
case float64:
|
||||
return lua.LNumber(val)
|
||||
case string:
|
||||
return lua.LString(val)
|
||||
case []interface{}:
|
||||
tbl := L.NewTable()
|
||||
for i, item := range val {
|
||||
tbl.RawSetInt(i+1, goToLua(L, item))
|
||||
}
|
||||
return tbl
|
||||
case map[string]interface{}:
|
||||
tbl := L.NewTable()
|
||||
for k, item := range val {
|
||||
L.SetField(tbl, k, goToLua(L, item))
|
||||
}
|
||||
return tbl
|
||||
default:
|
||||
return lua.LString(fmt.Sprintf("%v", val))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"greq/internal/storage"
|
||||
)
|
||||
|
||||
|
||||
// RunPreScript executes rf.PreScript before sending the request.
|
||||
// The script receives REQUEST_URL, REQUEST_METHOD, REQUEST_BODY as env vars.
|
||||
// Lines of stdout matching "Key: Value" are returned as extra headers to merge
|
||||
// into the request. A non-zero exit is treated as an error (request is aborted).
|
||||
func RunPreScript(script string, rf storage.RequestFile, envs map[string]string) (map[string]string, error) {
|
||||
if strings.TrimSpace(script) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out, err := runScript(script, map[string]string{
|
||||
"REQUEST_URL": ResolveVariables(rf.URL, envs),
|
||||
"REQUEST_METHOD": rf.Method,
|
||||
"REQUEST_BODY": rf.Body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pre_script failed: %w\nOutput: %s", err, out)
|
||||
}
|
||||
|
||||
headers := make(map[string]string)
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if k, v, ok := splitScriptHeader(line); ok {
|
||||
headers[k] = v
|
||||
}
|
||||
}
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// RunTestScript executes the test_script field of a request using the Lua runner.
|
||||
// See RunLuaTestScript for available globals and helper functions.
|
||||
func RunTestScript(script string, resp *Response) ([]TestResult, error) {
|
||||
return RunLuaTestScript(script, resp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func runScript(script string, vars map[string]string) (string, error) {
|
||||
f, err := os.CreateTemp("", "greq-script-*.sh")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
|
||||
if _, err := f.WriteString("#!/bin/sh\n" + script + "\n"); err != nil {
|
||||
f.Close()
|
||||
return "", err
|
||||
}
|
||||
f.Close()
|
||||
|
||||
if err := os.Chmod(f.Name(), 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", f.Name())
|
||||
// Start with the current process env, then overlay our custom vars
|
||||
// so the script has access to PATH, HOME, etc.
|
||||
cmd.Env = os.Environ()
|
||||
for k, v := range vars {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
|
||||
err = cmd.Run()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func splitScriptHeader(s string) (key, val string, ok bool) {
|
||||
idx := strings.Index(s, ": ")
|
||||
if idx < 1 {
|
||||
return "", "", false
|
||||
}
|
||||
k := strings.TrimSpace(s[:idx])
|
||||
v := strings.TrimSpace(s[idx+2:])
|
||||
// Reject lines that look like script output, not headers
|
||||
if strings.ContainsAny(k, " \t=/") {
|
||||
return "", "", false
|
||||
}
|
||||
return k, v, true
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"greq/internal/storage"
|
||||
)
|
||||
|
||||
// StressCount is the number of requests fired in a single stress run.
|
||||
const StressCount = 100
|
||||
|
||||
// StressConcurrency is the number of parallel workers.
|
||||
const StressConcurrency = 10
|
||||
|
||||
// StressResult holds the aggregated statistics of a load test.
|
||||
type StressResult struct {
|
||||
Total int
|
||||
Errors int
|
||||
MinDur time.Duration
|
||||
MaxDur time.Duration
|
||||
AvgDur time.Duration
|
||||
}
|
||||
|
||||
// ErrRate returns the error percentage (0–100).
|
||||
func (r StressResult) ErrRate() float64 {
|
||||
if r.Total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(r.Errors) / float64(r.Total) * 100
|
||||
}
|
||||
|
||||
// RunStress fires StressCount HTTP requests against rf with StressConcurrency
|
||||
// parallel workers and returns aggregated timing statistics.
|
||||
func RunStress(rf storage.RequestFile, envs map[string]string) StressResult {
|
||||
type singleResult struct {
|
||||
dur time.Duration
|
||||
err bool
|
||||
}
|
||||
|
||||
results := make([]singleResult, StressCount)
|
||||
sem := make(chan struct{}, StressConcurrency)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < StressCount; i++ {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
|
||||
start := time.Now()
|
||||
_, err := Do(rf, envs)
|
||||
dur := time.Since(start)
|
||||
|
||||
mu.Lock()
|
||||
results[idx] = singleResult{dur: dur, err: err != nil}
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
var errCount int
|
||||
var sumDur, minDur, maxDur time.Duration
|
||||
|
||||
for _, r := range results {
|
||||
if r.err {
|
||||
errCount++
|
||||
}
|
||||
sumDur += r.dur
|
||||
if minDur == 0 || r.dur < minDur {
|
||||
minDur = r.dur
|
||||
}
|
||||
if r.dur > maxDur {
|
||||
maxDur = r.dur
|
||||
}
|
||||
}
|
||||
|
||||
avgDur := time.Duration(int64(sumDur) / int64(StressCount))
|
||||
|
||||
return StressResult{
|
||||
Total: StressCount,
|
||||
Errors: errCount,
|
||||
MinDur: minDur,
|
||||
MaxDur: maxDur,
|
||||
AvgDur: avgDur,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user