Initial commit

This commit is contained in:
Mester Gábor
2026-03-19 07:12:03 +01:00
commit 2dd6519168
25 changed files with 3645 additions and 0 deletions
+54
View File
@@ -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)
}
}
}
+137
View File
@@ -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
}
}
}
}
+218
View File
@@ -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, "'", "'\\''") + "'"
}
+167
View File
@@ -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))
}
}
+102
View File
@@ -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
}
+90
View File
@@ -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 (0100).
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,
}
}