feat: implement http client app MVP

This commit is contained in:
曾志威
2026-06-07 19:23:08 +08:00
parent da20c77b06
commit 9bc8275e7f
23 changed files with 5629 additions and 155 deletions
+7 -1
View File
@@ -4,14 +4,20 @@ import (
"errors"
"log"
"net/http"
"os"
"time"
"github.com/local/http-client-app/backend/internal/api"
)
func main() {
addr := os.Getenv("HTTP_CLIENT_ADDR")
if addr == "" {
addr = "127.0.0.1:32180"
}
server := &http.Server{
Addr: "127.0.0.1:32180",
Addr: addr,
Handler: api.NewRouter(),
ReadHeaderTimeout: 5 * time.Second,
}
+5 -3
View File
@@ -2,7 +2,11 @@ module github.com/local/http-client-app/backend
go 1.26.0
require github.com/gin-gonic/gin v1.11.0
require (
github.com/gin-gonic/gin v1.11.0
github.com/goccy/go-yaml v1.18.0
golang.org/x/net v0.42.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
@@ -14,7 +18,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
@@ -30,7 +33,6 @@ require (
golang.org/x/arch v0.20.0 // indirect
golang.org/x/crypto v0.40.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
File diff suppressed because it is too large Load Diff
+223
View File
@@ -1,12 +1,15 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/local/http-client-app/backend/internal/api"
"golang.org/x/net/websocket"
)
func TestHealthEndpointReturnsEnvelope(t *testing.T) {
@@ -51,3 +54,223 @@ func TestHealthEndpointReturnsEnvelope(t *testing.T) {
t.Fatalf("expected timestamp to be set")
}
}
func TestParseEndpointReturnsRequestBlocks(t *testing.T) {
router := api.NewRouter()
rec := postJSON(router, "/api/parse", `{
"content": "@baseUrl = https://example.test\n\n# @name list\nGET {{baseUrl}}/users\nAccept: application/json\n"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
body := decodeEnvelope(t, rec)
data := body["data"].(map[string]any)
requests := data["requests"].([]any)
if len(requests) != 1 {
t.Fatalf("expected one parsed request, got %#v", requests)
}
request := requests[0].(map[string]any)
if request["name"] != "list" || request["method"] != "GET" {
t.Fatalf("unexpected parsed request: %#v", request)
}
}
func TestSignatureEndpointCalculatesHMAC(t *testing.T) {
router := api.NewRouter()
rec := postJSON(router, "/api/signatures/calculate", `{
"algorithm": "hmac-sha256",
"data": "hello",
"secret": "secret",
"encoding": "hex"
}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
body := decodeEnvelope(t, rec)
data := body["data"].(map[string]any)
if data["value"] != "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b" {
t.Fatalf("unexpected signature payload: %#v", data)
}
}
func TestExecutionEndpointRunsHTTPRequest(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer target.Close()
router := api.NewRouter()
rec := postJSON(router, "/api/executions", `{
"type": "http",
"request": {
"method": "GET",
"url": "`+target.URL+`",
"headers": {"Accept": "application/json"}
}
}`)
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String())
}
body := decodeEnvelope(t, rec)
data := body["data"].(map[string]any)
if data["status"] != "succeeded" {
t.Fatalf("expected succeeded execution, got %#v", data)
}
result := data["result"].(map[string]any)
if result["statusCode"] != float64(http.StatusOK) {
t.Fatalf("expected statusCode 200, got %#v", result)
}
if !strings.Contains(result["body"].(string), `"ok":true`) {
t.Fatalf("expected response body, got %#v", result["body"])
}
}
func TestExecutionHistoryAndEventsEndpoints(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer target.Close()
router := api.NewRouter()
created := postJSON(router, "/api/executions", `{
"type": "http",
"request": {"method": "GET", "url": "`+target.URL+`"}
}`)
createdBody := decodeEnvelope(t, created)
executionID := createdBody["data"].(map[string]any)["id"].(string)
historyReq := httptest.NewRequest(http.MethodGet, "/api/history", nil)
historyRec := httptest.NewRecorder()
router.ServeHTTP(historyRec, historyReq)
history := decodeEnvelope(t, historyRec)["data"].(map[string]any)
if len(history["items"].([]any)) == 0 {
t.Fatalf("expected history items, got %#v", history)
}
eventsReq := httptest.NewRequest(http.MethodGet, "/api/events", nil)
eventsRec := httptest.NewRecorder()
router.ServeHTTP(eventsRec, eventsReq)
events := decodeEnvelope(t, eventsRec)["data"].(map[string]any)
if len(events["events"].([]any)) == 0 {
t.Fatalf("expected global events, got %#v", events)
}
perExecutionReq := httptest.NewRequest(http.MethodGet, "/api/executions/"+executionID+"/events", nil)
perExecutionRec := httptest.NewRecorder()
router.ServeHTTP(perExecutionRec, perExecutionReq)
perExecutionEvents := decodeEnvelope(t, perExecutionRec)["data"].(map[string]any)
if len(perExecutionEvents["events"].([]any)) == 0 {
t.Fatalf("expected per-execution events, got %#v", perExecutionEvents)
}
}
func TestCurlExportAndPostmanImport(t *testing.T) {
router := api.NewRouter()
curlRec := postJSON(router, "/api/export/curl", `{
"request": {
"method": "POST",
"url": "https://example.test/users",
"headers": {"Content-Type": "application/json"},
"body": "{\"name\":\"Zoe\"}"
}
}`)
curlData := decodeEnvelope(t, curlRec)["data"].(map[string]any)
if !strings.Contains(curlData["curl"].(string), "curl -X POST") {
t.Fatalf("expected curl command, got %#v", curlData)
}
postmanRec := postJSON(router, "/api/import/postman", `{
"collection": {
"item": [
{
"name": "List users",
"request": {
"method": "GET",
"url": "https://example.test/users"
}
}
]
}
}`)
postmanData := decodeEnvelope(t, postmanRec)["data"].(map[string]any)
if !strings.Contains(postmanData["requests"].(string), "# @name List users") {
t.Fatalf("expected generated .http request, got %#v", postmanData)
}
}
func TestSSEAndWebSocketExecutionTypesUseLiveTransports(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: message\ndata: hello\n\n"))
})
mux.Handle("/socket", websocket.Handler(func(conn *websocket.Conn) {
var message string
if err := websocket.Message.Receive(conn, &message); err != nil {
t.Errorf("failed to receive websocket message: %v", err)
return
}
if err := websocket.Message.Send(conn, "echo:"+message); err != nil {
t.Errorf("failed to send websocket message: %v", err)
}
}))
target := httptest.NewServer(mux)
defer target.Close()
router := api.NewRouter()
sseRec := postJSON(router, "/api/executions", `{
"type": "sse",
"request": {"method": "GET", "url": "`+target.URL+`/events"},
"options": {"maxEvents": 1}
}`)
sseData := decodeEnvelope(t, sseRec)["data"].(map[string]any)
if sseData["status"] != "succeeded" {
t.Fatalf("expected sse succeeded, got %#v", sseData)
}
wsRec := postJSON(router, "/api/executions", `{
"type": "websocket",
"request": {"method": "GET", "url": "`+"ws"+strings.TrimPrefix(target.URL, "http")+`/socket"},
"options": {"messages": ["hello"]}
}`)
wsData := decodeEnvelope(t, wsRec)["data"].(map[string]any)
if wsData["status"] != "succeeded" {
t.Fatalf("expected websocket succeeded, got %#v", wsData)
}
result := wsData["result"].(map[string]any)
received := result["received"].([]any)
if len(received) != 1 || received[0] != "echo:hello" {
t.Fatalf("expected websocket echo response, got %#v", result)
}
}
func postJSON(router http.Handler, path string, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("response is not JSON: %v", err)
}
if body["success"] != true {
t.Fatalf("expected success=true, got %#v", body)
}
return body
}
+673
View File
@@ -0,0 +1,673 @@
package execution
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/websocket"
)
const (
TypeHTTP = "http"
TypeBatch = "batch"
TypeChain = "chain"
TypeLoadTest = "load-test"
TypeSSE = "sse"
TypeWebSocket = "websocket"
)
const (
StatusQueued = "queued"
StatusRunning = "running"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
StatusCancelled = "cancelled"
)
type RequestItem struct {
Name string `json:"name,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers,omitempty"`
Body string `json:"body,omitempty"`
}
type CreateRequest struct {
Type string `json:"type"`
Request *RequestItem `json:"request,omitempty"`
Requests []RequestItem `json:"requests,omitempty"`
FilePath string `json:"filePath,omitempty"`
Content string `json:"content,omitempty"`
Environment string `json:"environment,omitempty"`
Options map[string]any `json:"options,omitempty"`
}
type Execution struct {
ID string `json:"id"`
Type string `json:"type"`
Status string `json:"status"`
StartedAt string `json:"startedAt"`
CompletedAt *string `json:"completedAt"`
Result map[string]any `json:"result,omitempty"`
Children []Execution `json:"children,omitempty"`
Error string `json:"error,omitempty"`
}
type ResponseResult struct {
StatusCode int `json:"statusCode"`
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
DurationMS int64 `json:"durationMs"`
}
type Event struct {
Type string `json:"type"`
ExecutionID string `json:"executionId"`
Seq int64 `json:"seq"`
Timestamp string `json:"timestamp"`
Payload map[string]any `json:"payload"`
}
type Store struct {
mu sync.RWMutex
nextID atomic.Int64
client *http.Client
executions map[string]Execution
events map[string][]Event
order []string
}
func NewStore() *Store {
return &Store{
client: &http.Client{
Timeout: 30 * time.Second,
},
executions: map[string]Execution{},
events: map[string][]Event{},
}
}
func (s *Store) Create(request CreateRequest) Execution {
exec := Execution{
ID: s.nextExecutionID(),
Type: normalizeType(request.Type),
Status: StatusRunning,
StartedAt: now(),
Result: map[string]any{},
}
s.save(exec)
s.appendEvent(exec.ID, "execution.started", map[string]any{"type": exec.Type})
switch exec.Type {
case TypeHTTP:
exec = s.runHTTPExecution(exec, request.Request, request.Options)
case TypeBatch:
exec = s.runBatchExecution(exec, request.Requests)
case TypeChain:
exec = s.runChainExecution(exec, request.Requests)
case TypeLoadTest:
exec = s.runLoadTestExecution(exec, request.Request, request.Options)
case TypeSSE:
exec = s.runSSEExecution(exec, request.Request, request.Options)
case TypeWebSocket:
exec = s.runWebSocketExecution(exec, request.Request, request.Options)
default:
exec = fail(exec, fmt.Errorf("unsupported execution type %q", request.Type))
}
s.save(exec)
return exec
}
func (s *Store) Get(id string) (Execution, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
exec, ok := s.executions[id]
return exec, ok
}
func (s *Store) Events(id string) []Event {
s.mu.RLock()
defer s.mu.RUnlock()
events := s.events[id]
copied := make([]Event, len(events))
copy(copied, events)
return copied
}
func (s *Store) AllEvents() []Event {
s.mu.RLock()
defer s.mu.RUnlock()
var events []Event
for _, id := range s.order {
events = append(events, s.events[id]...)
}
return events
}
func (s *Store) History(limit int) []Execution {
s.mu.RLock()
defer s.mu.RUnlock()
if limit <= 0 || limit > len(s.order) {
limit = len(s.order)
}
history := make([]Execution, 0, limit)
for index := len(s.order) - 1; index >= 0 && len(history) < limit; index-- {
history = append(history, s.executions[s.order[index]])
}
return history
}
func (s *Store) Cancel(id string) (Execution, bool) {
s.mu.Lock()
defer s.mu.Unlock()
exec, ok := s.executions[id]
if !ok {
return Execution{}, false
}
if exec.CompletedAt == nil {
completed := now()
exec.Status = StatusCancelled
exec.CompletedAt = &completed
exec.Result = map[string]any{"cancelled": true}
s.executions[id] = exec
}
return exec, true
}
func (s *Store) runHTTPExecution(exec Execution, item *RequestItem, options map[string]any) Execution {
if item == nil {
return fail(exec, fmt.Errorf("request is required for http execution"))
}
response, err := s.send(*item)
if err != nil {
return fail(exec, err)
}
exec.Result = map[string]any{
"response": response,
"statusCode": response.StatusCode,
"status": response.StatusCode,
"headers": response.Headers,
"body": response.Body,
"durationMs": response.DurationMS,
"scriptLogs": scriptLogs(options),
}
assertions, err := evaluateAssertions(response, options)
exec.Result["assertions"] = assertions
if err != nil {
return fail(exec, err)
}
exec = succeed(exec)
s.appendEvent(exec.ID, "execution.response", map[string]any{"statusCode": response.StatusCode, "durationMs": response.DurationMS})
return exec
}
func (s *Store) runBatchExecution(exec Execution, requests []RequestItem) Execution {
if len(requests) == 0 {
return fail(exec, fmt.Errorf("requests are required for batch execution"))
}
children := make([]Execution, 0, len(requests))
for _, request := range requests {
child := Execution{
ID: s.nextExecutionID(),
Type: TypeHTTP,
Status: StatusRunning,
StartedAt: now(),
Result: map[string]any{},
}
child = s.runHTTPExecution(child, &request, nil)
children = append(children, child)
s.save(child)
}
exec.Children = children
exec.Result = map[string]any{"total": len(children), "succeeded": countStatus(children, StatusSucceeded)}
if countStatus(children, StatusFailed) > 0 {
return fail(exec, fmt.Errorf("%d batch request(s) failed", countStatus(children, StatusFailed)))
}
return succeed(exec)
}
func (s *Store) runChainExecution(exec Execution, requests []RequestItem) Execution {
if len(requests) == 0 {
return fail(exec, fmt.Errorf("requests are required for chain execution"))
}
variables := map[string]string{}
children := make([]Execution, 0, len(requests))
for index, request := range requests {
request.URL = replaceSimpleVariables(request.URL, variables)
request.Body = replaceSimpleVariables(request.Body, variables)
for key, value := range request.Headers {
request.Headers[key] = replaceSimpleVariables(value, variables)
}
child := Execution{
ID: s.nextExecutionID(),
Type: TypeHTTP,
Status: StatusRunning,
StartedAt: now(),
Result: map[string]any{},
}
child = s.runHTTPExecution(child, &request, nil)
children = append(children, child)
if child.Status != StatusSucceeded {
exec.Children = children
return fail(exec, fmt.Errorf("chain step %d failed", index+1))
}
if response, ok := child.Result["response"].(ResponseResult); ok {
variables[fmt.Sprintf("step%d.status", index+1)] = fmt.Sprintf("%d", response.StatusCode)
variables[fmt.Sprintf("step%d.body", index+1)] = response.Body
}
}
exec.Children = children
exec.Result = map[string]any{"total": len(children), "succeeded": len(children)}
return succeed(exec)
}
func (s *Store) runSSEExecution(exec Execution, item *RequestItem, options map[string]any) Execution {
if item == nil {
return fail(exec, fmt.Errorf("request is required for sse execution"))
}
request := *item
if request.Headers == nil {
request.Headers = map[string]string{}
}
request.Headers["Accept"] = "text/event-stream"
response, err := s.send(request)
if err != nil {
return fail(exec, err)
}
maxEvents := intOption(options, "maxEvents", 20)
events := parseSSEEvents(response.Body, maxEvents)
exec.Result = map[string]any{
"sessionType": "sse",
"statusCode": response.StatusCode,
"events": events,
"durationMs": response.DurationMS,
"bodySample": response.Body,
}
return succeed(exec)
}
func (s *Store) runWebSocketExecution(exec Execution, item *RequestItem, options map[string]any) Execution {
if item == nil {
return fail(exec, fmt.Errorf("request is required for websocket execution"))
}
messages := stringSliceOption(options, "messages")
timeout := time.Duration(intOption(options, "timeoutMs", 5000)) * time.Millisecond
if timeout <= 0 {
timeout = 5 * time.Second
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
config, err := websocket.NewConfig(item.URL, websocketOrigin(item.URL))
if err != nil {
return fail(exec, err)
}
for key, value := range item.Headers {
config.Header.Set(key, value)
}
conn, err := config.DialContext(ctx)
if err != nil {
return fail(exec, err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(timeout))
received := []string{}
for _, message := range messages {
if err := websocket.Message.Send(conn, message); err != nil {
return fail(exec, err)
}
var response string
if err := websocket.Message.Receive(conn, &response); err != nil {
return fail(exec, err)
}
received = append(received, response)
}
exec.Result = map[string]any{
"sessionType": "websocket",
"url": item.URL,
"sent": messages,
"received": received,
}
return succeed(exec)
}
func (s *Store) runLoadTestExecution(exec Execution, item *RequestItem, options map[string]any) Execution {
if item == nil {
return fail(exec, fmt.Errorf("request is required for load-test execution"))
}
total := intOption(options, "requests", 1)
if total <= 0 {
total = 1
}
concurrency := intOption(options, "concurrency", 1)
if concurrency <= 0 {
concurrency = 1
}
if concurrency > total {
concurrency = total
}
started := time.Now()
jobs := make(chan int)
var succeeded atomic.Int64
var failed atomic.Int64
var maxDuration atomic.Int64
var totalDuration atomic.Int64
var wg sync.WaitGroup
for worker := 0; worker < concurrency; worker++ {
wg.Add(1)
go func() {
defer wg.Done()
for range jobs {
response, err := s.send(*item)
if err != nil || response.StatusCode >= 500 {
failed.Add(1)
continue
}
succeeded.Add(1)
totalDuration.Add(response.DurationMS)
for {
oldMax := maxDuration.Load()
if response.DurationMS <= oldMax || maxDuration.CompareAndSwap(oldMax, response.DurationMS) {
break
}
}
}
}()
}
for index := 0; index < total; index++ {
jobs <- index
}
close(jobs)
wg.Wait()
duration := time.Since(started).Milliseconds()
successCount := int(succeeded.Load())
avg := int64(0)
if successCount > 0 {
avg = totalDuration.Load() / int64(successCount)
}
exec.Result = map[string]any{
"totalRequests": total,
"succeeded": successCount,
"failed": int(failed.Load()),
"concurrency": concurrency,
"durationMs": duration,
"avgDurationMs": avg,
"maxDurationMs": maxDuration.Load(),
}
if failed.Load() > 0 {
return fail(exec, fmt.Errorf("%d load-test request(s) failed", failed.Load()))
}
return succeed(exec)
}
func (s *Store) send(item RequestItem) (ResponseResult, error) {
method := strings.ToUpper(strings.TrimSpace(item.Method))
if method == "" {
method = http.MethodGet
}
req, err := http.NewRequestWithContext(context.Background(), method, item.URL, bytes.NewBufferString(item.Body))
if err != nil {
return ResponseResult{}, err
}
for key, value := range item.Headers {
req.Header.Set(key, value)
}
started := time.Now()
resp, err := s.client.Do(req)
if err != nil {
return ResponseResult{}, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return ResponseResult{}, err
}
headers := map[string]string{}
for key, values := range resp.Header {
headers[key] = strings.Join(values, ", ")
}
return ResponseResult{
StatusCode: resp.StatusCode,
Status: resp.StatusCode,
Headers: headers,
Body: string(body),
DurationMS: time.Since(started).Milliseconds(),
}, nil
}
func (s *Store) nextExecutionID() string {
return fmt.Sprintf("exec_%d_%d", time.Now().UnixNano(), s.nextID.Add(1))
}
func (s *Store) save(exec Execution) {
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.executions[exec.ID]; !exists {
s.order = append(s.order, exec.ID)
}
s.executions[exec.ID] = exec
}
func (s *Store) appendEvent(executionID, eventType string, payload map[string]any) {
s.mu.Lock()
defer s.mu.Unlock()
seq := int64(len(s.events[executionID]))
s.events[executionID] = append(s.events[executionID], Event{
Type: eventType,
ExecutionID: executionID,
Seq: seq,
Timestamp: now(),
Payload: payload,
})
}
func normalizeType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return TypeHTTP
}
return value
}
func succeed(exec Execution) Execution {
completed := now()
exec.Status = StatusSucceeded
exec.CompletedAt = &completed
return exec
}
func fail(exec Execution, err error) Execution {
completed := now()
exec.Status = StatusFailed
exec.CompletedAt = &completed
exec.Error = err.Error()
if exec.Result == nil {
exec.Result = map[string]any{}
}
exec.Result["error"] = err.Error()
return exec
}
func now() string {
return time.Now().UTC().Format(time.RFC3339)
}
func countStatus(children []Execution, status string) int {
count := 0
for _, child := range children {
if child.Status == status {
count++
}
}
return count
}
func intOption(options map[string]any, key string, fallback int) int {
if options == nil {
return fallback
}
switch value := options[key].(type) {
case int:
return value
case int64:
return int(value)
case float64:
return int(value)
case json.Number:
parsed, err := value.Int64()
if err == nil {
return int(parsed)
}
case string:
var parsed int
if _, err := fmt.Sscanf(value, "%d", &parsed); err == nil {
return parsed
}
}
return fallback
}
func stringSliceOption(options map[string]any, key string) []string {
if options == nil {
return []string{}
}
switch values := options[key].(type) {
case []string:
return values
case []any:
messages := make([]string, 0, len(values))
for _, value := range values {
messages = append(messages, fmt.Sprint(value))
}
return messages
default:
return []string{}
}
}
func websocketOrigin(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return "http://127.0.0.1"
}
scheme := "http"
if parsed.Scheme == "wss" {
scheme = "https"
}
return scheme + "://" + parsed.Host
}
func replaceSimpleVariables(input string, variables map[string]string) string {
output := input
for key, value := range variables {
output = strings.ReplaceAll(output, "{{"+key+"}}", value)
}
return output
}
func scriptLogs(options map[string]any) []string {
if options == nil {
return []string{}
}
logs := []string{}
if preScript, ok := options["preScript"].(string); ok && strings.TrimSpace(preScript) != "" {
logs = append(logs, "preScript accepted")
}
if postScript, ok := options["postScript"].(string); ok && strings.TrimSpace(postScript) != "" {
logs = append(logs, "postScript accepted")
}
return logs
}
func evaluateAssertions(response ResponseResult, options map[string]any) ([]map[string]any, error) {
assertions := []map[string]any{}
if options == nil {
return assertions, nil
}
if expectedStatus := intOption(options, "assertStatus", 0); expectedStatus > 0 {
passed := response.StatusCode == expectedStatus
assertions = append(assertions, map[string]any{
"name": "status",
"expected": expectedStatus,
"actual": response.StatusCode,
"passed": passed,
})
if !passed {
return assertions, fmt.Errorf("assertion failed: expected status %d, got %d", expectedStatus, response.StatusCode)
}
}
if contains, ok := options["assertBodyContains"].(string); ok && contains != "" {
passed := strings.Contains(response.Body, contains)
assertions = append(assertions, map[string]any{
"name": "body contains",
"expected": contains,
"passed": passed,
})
if !passed {
return assertions, fmt.Errorf("assertion failed: response body does not contain %q", contains)
}
}
return assertions, nil
}
func parseSSEEvents(body string, limit int) []map[string]string {
if limit <= 0 {
limit = 20
}
blocks := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n\n")
events := []map[string]string{}
for _, block := range blocks {
if strings.TrimSpace(block) == "" {
continue
}
event := map[string]string{}
for _, line := range strings.Split(block, "\n") {
key, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
event[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
if len(event) > 0 {
events = append(events, event)
}
if len(events) >= limit {
break
}
}
return events
}
@@ -0,0 +1,167 @@
package execution_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/local/http-client-app/backend/internal/execution"
"golang.org/x/net/websocket"
)
func TestRunHTTPExecutionSendsRequestAndCapturesResponse(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if r.Header.Get("X-Test") != "yes" {
t.Fatalf("expected X-Test header")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer target.Close()
store := execution.NewStore()
result := store.Create(execution.CreateRequest{
Type: execution.TypeHTTP,
Request: &execution.RequestItem{
Method: "POST",
URL: target.URL,
Headers: map[string]string{"X-Test": "yes"},
Body: `{"name":"Zoe"}`,
},
})
if result.Status != execution.StatusSucceeded {
t.Fatalf("expected succeeded, got %s", result.Status)
}
response, ok := result.Result["response"].(execution.ResponseResult)
if !ok {
t.Fatalf("expected response result, got %#v", result.Result)
}
if response.StatusCode != http.StatusCreated {
t.Fatalf("expected status 201, got %d", response.StatusCode)
}
if response.Body != `{"ok":true}` {
t.Fatalf("unexpected body: %s", response.Body)
}
}
func TestRunBatchExecutionCreatesChildren(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
}))
defer target.Close()
store := execution.NewStore()
result := store.Create(execution.CreateRequest{
Type: execution.TypeBatch,
Requests: []execution.RequestItem{
{Method: "GET", URL: target.URL + "/one"},
{Method: "GET", URL: target.URL + "/two"},
},
})
if result.Status != execution.StatusSucceeded {
t.Fatalf("expected succeeded, got %s", result.Status)
}
if len(result.Children) != 2 {
t.Fatalf("expected two child executions, got %d", len(result.Children))
}
}
func TestRunLoadTestReportsTotals(t *testing.T) {
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
store := execution.NewStore()
result := store.Create(execution.CreateRequest{
Type: execution.TypeLoadTest,
Request: &execution.RequestItem{
Method: "GET",
URL: target.URL,
},
Options: map[string]any{
"requests": float64(4),
"concurrency": float64(2),
},
})
if result.Status != execution.StatusSucceeded {
t.Fatalf("expected succeeded, got %s", result.Status)
}
if result.Result["totalRequests"] != 4 {
t.Fatalf("expected totalRequests=4, got %#v", result.Result)
}
if result.Result["succeeded"] != 4 {
t.Fatalf("expected succeeded=4, got %#v", result.Result)
}
}
func TestRunWebSocketExecutionDialsAndExchangesMessages(t *testing.T) {
target := httptest.NewServer(websocket.Handler(func(conn *websocket.Conn) {
var message string
if err := websocket.Message.Receive(conn, &message); err != nil {
t.Errorf("failed to receive websocket message: %v", err)
return
}
if err := websocket.Message.Send(conn, "echo:"+message); err != nil {
t.Errorf("failed to send websocket message: %v", err)
}
}))
defer target.Close()
store := execution.NewStore()
result := store.Create(execution.CreateRequest{
Type: execution.TypeWebSocket,
Request: &execution.RequestItem{
Method: "GET",
URL: "ws" + strings.TrimPrefix(target.URL, "http"),
},
Options: map[string]any{
"messages": []any{"hello"},
},
})
if result.Status != execution.StatusSucceeded {
t.Fatalf("expected succeeded, got %s: %#v", result.Status, result)
}
received, ok := result.Result["received"].([]string)
if !ok {
t.Fatalf("expected received websocket messages, got %#v", result.Result)
}
if len(received) != 1 || received[0] != "echo:hello" {
t.Fatalf("expected echo response, got %#v", received)
}
}
func TestRunWebSocketExecutionFailsWhenEndpointIsNotWebSocket(t *testing.T) {
target := httptest.NewServer(http.NotFoundHandler())
defer target.Close()
store := execution.NewStore()
result := store.Create(execution.CreateRequest{
Type: execution.TypeWebSocket,
Request: &execution.RequestItem{
Method: "GET",
URL: "ws" + strings.TrimPrefix(target.URL, "http"),
},
Options: map[string]any{
"messages": []any{"hello"},
},
})
if result.Status != execution.StatusFailed {
t.Fatalf("expected failed websocket execution, got %#v", result)
}
if result.Error == "" {
t.Fatalf("expected websocket failure error")
}
}
+14
View File
@@ -26,3 +26,17 @@ func SuccessEnvelope(requestID string, data any) Envelope {
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
func ErrorEnvelope(requestID string, code string, message string, details any) Envelope {
return Envelope{
Success: false,
Data: nil,
Error: &ErrorResponse{
Code: code,
Message: message,
Details: details,
},
RequestID: requestID,
Timestamp: time.Now().UTC().Format(time.RFC3339),
}
}
+234
View File
@@ -0,0 +1,234 @@
package parser
import (
"crypto/sha1"
"encoding/hex"
"fmt"
"regexp"
"strings"
)
var (
requestLinePattern = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|TRACE)\s+(\S+)(?:\s+HTTP/\d(?:\.\d)?)?\s*$`)
variablePattern = regexp.MustCompile(`\{\{\s*([A-Za-z_][A-Za-z0-9_.-]*)\s*\}\}`)
)
type RequestBlock struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
StartLine int `json:"startLine"`
EndLine int `json:"endLine"`
}
type ParseResult struct {
FilePath string `json:"filePath,omitempty"`
Requests []RequestBlock `json:"requests"`
Variables map[string]string `json:"variables"`
Errors []string `json:"errors"`
}
func Parse(content, filePath string) ParseResult {
normalized := strings.ReplaceAll(strings.TrimPrefix(content, "\ufeff"), "\r\n", "\n")
normalized = strings.ReplaceAll(normalized, "\r", "\n")
lines := strings.Split(normalized, "\n")
result := ParseResult{
FilePath: filePath,
Variables: map[string]string{},
Requests: []RequestBlock{},
Errors: []string{},
}
for _, segment := range splitSegments(lines) {
parseSegment(segment.lines, segment.startLine, filePath, &result)
}
return result
}
type segment struct {
lines []string
startLine int
}
func splitSegments(lines []string) []segment {
var segments []segment
current := segment{startLine: 1}
for index, line := range lines {
if strings.TrimSpace(line) == "###" {
if hasContent(current.lines) {
segments = append(segments, current)
}
current = segment{startLine: index + 2}
continue
}
current.lines = append(current.lines, line)
}
if hasContent(current.lines) {
segments = append(segments, current)
}
return segments
}
func hasContent(lines []string) bool {
for _, line := range lines {
if strings.TrimSpace(line) != "" {
return true
}
}
return false
}
func parseSegment(lines []string, startLine int, filePath string, result *ParseResult) {
name := ""
requestLineIndex := -1
for index, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "@") && !strings.Contains(trimmed, " ") {
continue
}
if strings.HasPrefix(trimmed, "@") {
if key, value, ok := parseVariable(trimmed); ok {
result.Variables[key] = value
continue
}
}
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") {
if extracted := parseName(trimmed); extracted != "" {
name = extracted
}
continue
}
if requestLinePattern.MatchString(trimmed) {
requestLineIndex = index
break
}
}
if requestLineIndex == -1 {
return
}
match := requestLinePattern.FindStringSubmatch(strings.TrimSpace(lines[requestLineIndex]))
request := RequestBlock{
Name: name,
Method: match[1],
URL: match[2],
Headers: map[string]string{},
StartLine: startLine + requestLineIndex,
EndLine: startLine + len(lines) - 1,
}
bodyStart := -1
for index := requestLineIndex + 1; index < len(lines); index++ {
line := lines[index]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
bodyStart = index + 1
break
}
if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "//") {
continue
}
key, value, ok := strings.Cut(line, ":")
if !ok {
bodyStart = index
break
}
request.Headers[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
if bodyStart >= 0 && bodyStart < len(lines) {
request.Body = strings.TrimSpace(strings.Join(lines[bodyStart:], "\n"))
}
request.ID = stableID(filePath, request)
result.Requests = append(result.Requests, request)
}
func parseVariable(line string) (string, string, bool) {
withoutAt := strings.TrimPrefix(line, "@")
key, value, ok := strings.Cut(withoutAt, "=")
if !ok {
return "", "", false
}
key = strings.TrimSpace(key)
if key == "" {
return "", "", false
}
return key, strings.TrimSpace(value), true
}
func parseName(line string) string {
trimmed := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(line, "#"), "//"))
if strings.HasPrefix(trimmed, "@name") {
return strings.TrimSpace(strings.TrimPrefix(trimmed, "@name"))
}
return ""
}
func stableID(filePath string, request RequestBlock) string {
h := sha1.New()
_, _ = h.Write([]byte(fmt.Sprintf("%s:%d:%s:%s:%s", filePath, request.StartLine, request.Name, request.Method, request.URL)))
return "req_" + hex.EncodeToString(h.Sum(nil))[:16]
}
func ResolveRequest(request RequestBlock, fileVariables map[string]string, environmentVariables map[string]string) (RequestBlock, error) {
variables := map[string]string{}
for key, value := range fileVariables {
variables[key] = value
}
for key, value := range environmentVariables {
variables[key] = value
}
replace := func(input string) (string, error) {
var missing []string
output := variablePattern.ReplaceAllStringFunc(input, func(match string) string {
name := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(match, "{{"), "}}"))
value, ok := variables[name]
if !ok {
missing = append(missing, name)
return match
}
return value
})
if len(missing) > 0 {
return "", fmt.Errorf("undefined variable(s): %s", strings.Join(missing, ", "))
}
return output, nil
}
resolved := request
var err error
if resolved.URL, err = replace(request.URL); err != nil {
return RequestBlock{}, err
}
if resolved.Body, err = replace(request.Body); err != nil {
return RequestBlock{}, err
}
resolved.Headers = map[string]string{}
for key, value := range request.Headers {
resolved.Headers[key], err = replace(value)
if err != nil {
return RequestBlock{}, err
}
}
return resolved, nil
}
+103
View File
@@ -0,0 +1,103 @@
package parser_test
import (
"testing"
"github.com/local/http-client-app/backend/internal/parser"
)
func TestParseHTTPFileFindsVariablesAndRequestBlocks(t *testing.T) {
document := `@baseUrl = https://example.test
@token = demo-token
# @name listUsers
GET {{baseUrl}}/users?limit=2
Accept: application/json
Authorization: Bearer {{token}}
###
# Create a user
# @name createUser
POST {{baseUrl}}/users
Content-Type: application/json
{"name":"Zoe"}
`
parsed := parser.Parse(document, "demo.http")
if len(parsed.Errors) != 0 {
t.Fatalf("expected no parse errors, got %#v", parsed.Errors)
}
if parsed.Variables["baseUrl"] != "https://example.test" {
t.Fatalf("expected baseUrl variable, got %#v", parsed.Variables)
}
if len(parsed.Requests) != 2 {
t.Fatalf("expected 2 requests, got %d", len(parsed.Requests))
}
first := parsed.Requests[0]
if first.Name != "listUsers" {
t.Fatalf("expected first request name listUsers, got %q", first.Name)
}
if first.Method != "GET" || first.URL != "{{baseUrl}}/users?limit=2" {
t.Fatalf("unexpected first request line: %s %s", first.Method, first.URL)
}
if first.Headers["Authorization"] != "Bearer {{token}}" {
t.Fatalf("expected authorization header, got %#v", first.Headers)
}
second := parsed.Requests[1]
if second.Name != "createUser" {
t.Fatalf("expected second request name createUser, got %q", second.Name)
}
if second.Body != `{"name":"Zoe"}` {
t.Fatalf("expected JSON body, got %q", second.Body)
}
}
func TestResolveVariablesUsesEnvironmentOverFileVariables(t *testing.T) {
request := parser.RequestBlock{
Method: "GET",
URL: "{{baseUrl}}/users/{{userId}}",
Headers: map[string]string{
"X-Token": "{{token}}",
},
Body: `{"app":"{{appName}}"}`,
}
resolved, err := parser.ResolveRequest(request, map[string]string{
"baseUrl": "https://file.example",
"userId": "1",
"token": "file-token",
}, map[string]string{
"baseUrl": "https://env.example",
"token": "env-token",
"appName": "demo",
})
if err != nil {
t.Fatalf("expected variables to resolve, got %v", err)
}
if resolved.URL != "https://env.example/users/1" {
t.Fatalf("unexpected resolved URL: %s", resolved.URL)
}
if resolved.Headers["X-Token"] != "env-token" {
t.Fatalf("unexpected resolved token: %#v", resolved.Headers)
}
if resolved.Body != `{"app":"demo"}` {
t.Fatalf("unexpected resolved body: %s", resolved.Body)
}
}
func TestResolveVariablesReportsUndefinedVariable(t *testing.T) {
_, err := parser.ResolveRequest(parser.RequestBlock{
Method: "GET",
URL: "{{missing}}",
Headers: map[string]string{},
}, nil, nil)
if err == nil {
t.Fatalf("expected undefined variable error")
}
}
+66
View File
@@ -0,0 +1,66 @@
package signature
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strings"
)
type Request struct {
Algorithm string `json:"algorithm"`
Data string `json:"data"`
Secret string `json:"secret,omitempty"`
Encoding string `json:"encoding,omitempty"`
}
type Result struct {
Algorithm string `json:"algorithm"`
Encoding string `json:"encoding"`
Value string `json:"value"`
}
func Calculate(request Request) (Result, error) {
algorithm := strings.ToLower(strings.TrimSpace(request.Algorithm))
encoding := strings.ToLower(strings.TrimSpace(request.Encoding))
if encoding == "" {
encoding = "hex"
}
var digest []byte
switch algorithm {
case "sha256":
sum := sha256.Sum256([]byte(request.Data))
digest = sum[:]
case "hmac-sha256":
mac := hmac.New(sha256.New, []byte(request.Secret))
_, _ = mac.Write([]byte(request.Data))
digest = mac.Sum(nil)
default:
return Result{}, fmt.Errorf("unsupported signature algorithm %q", request.Algorithm)
}
value, err := encode(digest, encoding)
if err != nil {
return Result{}, err
}
return Result{
Algorithm: algorithm,
Encoding: encoding,
Value: value,
}, nil
}
func encode(bytes []byte, encoding string) (string, error) {
switch encoding {
case "hex":
return hex.EncodeToString(bytes), nil
case "base64":
return base64.StdEncoding.EncodeToString(bytes), nil
default:
return "", fmt.Errorf("unsupported signature encoding %q", encoding)
}
}
@@ -0,0 +1,44 @@
package signature_test
import (
"testing"
"github.com/local/http-client-app/backend/internal/signature"
)
func TestCalculateHMACSHA256Hex(t *testing.T) {
result, err := signature.Calculate(signature.Request{
Algorithm: "hmac-sha256",
Data: "hello",
Secret: "secret",
Encoding: "hex",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Value != "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b" {
t.Fatalf("unexpected signature: %s", result.Value)
}
}
func TestCalculateSHA256Base64(t *testing.T) {
result, err := signature.Calculate(signature.Request{
Algorithm: "sha256",
Data: "hello",
Encoding: "base64",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if result.Value != "LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=" {
t.Fatalf("unexpected SHA-256 base64: %s", result.Value)
}
}
func TestCalculateRejectsUnsupportedAlgorithm(t *testing.T) {
if _, err := signature.Calculate(signature.Request{Algorithm: "md5", Data: "hello"}); err == nil {
t.Fatalf("expected unsupported algorithm error")
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
{
"schemaVersion": 1,
"defaultEnvironment": "local",
"environments": [
{
"name": "local",
"variables": {
"baseUrl": "http://127.0.0.1:32180",
"signature": "demo-signature"
}
},
{
"name": "example",
"variables": {
"baseUrl": "https://example.com",
"signature": "example-signature"
}
}
],
"globals": {
"appName": "http-client-app"
}
}
@@ -0,0 +1,33 @@
{
"schemaVersion": 1,
"updatedAt": "2026-06-07T00:00:00Z",
"rules": [
{
"id": "demo-list-users",
"name": "Demo list users",
"enabled": true,
"priority": 10,
"match": {
"method": "GET",
"path": "/api/users",
"pathMode": "exact"
},
"response": {
"statusCode": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyType": "json",
"body": {
"users": [
{
"id": 42,
"name": "Zoe"
}
]
},
"delayMs": 0
}
}
]
}
@@ -0,0 +1,52 @@
openapi: 3.1.0
info:
title: Demo API
version: 1.0.0
servers:
- url: https://example.com
paths:
/api/users:
get:
operationId: listUsers
tags:
- users
parameters:
- name: limit
in: query
required: false
schema:
type: integer
responses:
"200":
description: Users listed.
content:
application/json:
schema:
type: object
post:
operationId: createUser
tags:
- users
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
"201":
description: User created.
/api/private/{userId}:
get:
operationId: getPrivateUser
tags:
- private
parameters:
- name: userId
in: path
required: true
schema:
type: string
responses:
"200":
description: Private user details.
+23
View File
@@ -0,0 +1,23 @@
@baseUrl = https://example.com
@userId = 42
# @name listUsers
GET {{baseUrl}}/api/users?limit=2
Accept: application/json
###
# @name createUser
POST {{baseUrl}}/api/users
Content-Type: application/json
{
"name": "Zoe",
"role": "tester"
}
###
# @name signedRequest
GET {{baseUrl}}/api/private/{{userId}}
X-Signature: {{signature}}
+205 -4
View File
@@ -1,12 +1,213 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import App from './App.vue'
const parseResponse = {
requests: [
{
id: 'req-1',
name: 'List users',
method: 'GET',
url: '{{baseUrl}}/users',
headers: { Accept: 'application/json' },
},
{
id: 'req-2',
name: 'Create user',
method: 'POST',
url: '{{baseUrl}}/users',
body: '{"name":"Ada"}',
},
],
}
const executionResponse = {
id: 'exec-1',
status: 'completed',
response: {
status: 201,
headers: { 'content-type': 'application/json' },
body: '{"ok":true}',
durationMs: 42,
},
}
function jsonResponse(data: unknown, init: ResponseInit = {}) {
return Promise.resolve(
new Response(JSON.stringify(data), {
headers: { 'content-type': 'application/json' },
status: 200,
...init,
}),
)
}
describe('App', () => {
it('renders the application title', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const method = init?.method ?? 'GET'
if (url === '/api/health') {
return jsonResponse({ status: 'ok' })
}
if (url === '/api/parse' && method === 'POST') {
return jsonResponse(parseResponse)
}
if (url === '/api/executions' && method === 'POST') {
return jsonResponse(executionResponse)
}
if (url === '/api/signatures/calculate' && method === 'POST') {
return jsonResponse({ signature: 'sha256-demo', canonicalRequest: 'GET\n/users' })
}
if (url === '/api/environments?workspace=demo-workspace') {
return jsonResponse({
environments: {
local: { baseUrl: 'https://api.example.test', token: 'dev-token' },
},
defaultEnvironment: 'local',
})
}
if (url === '/api/environments' && method === 'POST') {
return jsonResponse({ saved: true })
}
if (url === '/api/mock-server/start' && method === 'POST') {
return jsonResponse({ id: 'mock-1', url: 'http://localhost:4010' })
}
if (url === '/api/openapi/import' && method === 'POST') {
return jsonResponse({ imported: 3 })
}
if (url === '/api/history') {
return jsonResponse({ items: [{ id: 'exec-1', status: 'succeeded' }] })
}
if (url === '/api/events') {
return jsonResponse({ events: [{ type: 'execution.started', executionId: 'exec-1' }] })
}
if (url === '/api/export/curl' && method === 'POST') {
return jsonResponse({ curl: "curl -X GET 'https://api.example.test/users'" })
}
if (url === '/api/import/postman' && method === 'POST') {
return jsonResponse({ requests: '# @name Imported\nGET https://api.example.test/users' })
}
return jsonResponse({ message: `Missing mock for ${method} ${url}` }, { status: 404 })
}),
)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders a three-column HTTP workbench and parses editable .http content', async () => {
const wrapper = mount(App)
expect(wrapper.get('h1').text()).toBe('HTTP Client App')
await flushPromises()
expect(wrapper.get('h1').text()).toContain('HTTP Client Workbench')
expect(wrapper.get('[data-testid="request-list-panel"]').text()).toContain('Requests')
expect(wrapper.get('[data-testid="editor-panel"]').text()).toContain('.http Editor')
expect(wrapper.get('[data-testid="response-panel"]').text()).toContain('Response')
expect(wrapper.get('[data-testid="health-status"]').text()).toContain('ok')
await wrapper.get('[data-testid="parse-button"]').trigger('click')
await flushPromises()
const requestList = wrapper.get('[data-testid="request-list"]')
expect(requestList.text()).toContain('List users')
expect(requestList.text()).toContain('GET')
expect(requestList.text()).toContain('{{baseUrl}}/users')
})
it('executes the selected request and renders response metadata', async () => {
const wrapper = mount(App)
await wrapper.get('[data-testid="parse-button"]').trigger('click')
await flushPromises()
await wrapper.get('[data-testid="execute-button"]').trigger('click')
await flushPromises()
const responsePanel = wrapper.get('[data-testid="response-panel"]')
expect(responsePanel.text()).toContain('201')
expect(responsePanel.text()).toContain('42 ms')
expect(responsePanel.text()).toContain('content-type')
expect(responsePanel.text()).toContain('"ok":true')
})
it('edits environments, previews variables, calculates signatures, and shows advanced API results', async () => {
const wrapper = mount(App)
await flushPromises()
await wrapper.get('[data-testid="load-environment-button"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="variable-preview"]').text()).toContain('baseUrl')
expect(wrapper.get('[data-testid="environment-select"]').element).toHaveProperty('value', 'local')
await wrapper.get('[data-testid="save-environment-button"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="environment-status"]').text()).toContain('saved')
await wrapper.get('[data-testid="signature-button"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="signature-result"]').text()).toContain('sha256-demo')
await wrapper.get('[data-testid="advanced-mode"]').setValue('mock')
await wrapper.get('[data-testid="advanced-run"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('mock-1')
await wrapper.get('[data-testid="advanced-mode"]').setValue('openapi')
await wrapper.get('[data-testid="advanced-run"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('imported')
await wrapper.get('[data-testid="advanced-mode"]').setValue('curl')
await wrapper.get('[data-testid="advanced-run"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('curl -X GET')
await wrapper.get('[data-testid="advanced-mode"]').setValue('history')
await wrapper.get('[data-testid="advanced-run"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('exec-1')
})
it('surfaces errors when optional backend APIs are unavailable', async () => {
vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => {
const url = String(input)
if (url === '/api/health') {
return jsonResponse({ status: 'ok' })
}
if (url === '/api/mock-server/start') {
return jsonResponse({ error: 'mock API unavailable' }, { status: 501 })
}
return jsonResponse(parseResponse)
})
const wrapper = mount(App)
await wrapper.get('[data-testid="advanced-mode"]').setValue('mock')
await wrapper.get('[data-testid="advanced-run"]').trigger('click')
await flushPromises()
expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('mock API unavailable')
})
})
+392 -20
View File
@@ -1,28 +1,400 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ApiRequestError, apiClient, type JsonObject, type ParsedRequest } from './api/httpClient'
const defaultHttpContent = `@baseUrl = http://127.0.0.1:32180
# @name health
GET {{baseUrl}}/api/health
Accept: application/json
###
# @name createUser
POST {{baseUrl}}/api/users
Content-Type: application/json
{"name":"Zoe"}`
const workspace = ref('demo-workspace')
const httpContent = ref(defaultHttpContent)
const parsedRequests = ref<ParsedRequest[]>([])
const selectedRequestId = ref('')
const healthStatus = ref('checking')
const parseStatus = ref('ready')
const executionStatus = ref('idle')
const executionResult = ref<JsonObject | null>(null)
const environmentName = ref('')
const environmentJson = ref('{\n "local": {\n "baseUrl": "http://127.0.0.1:32180"\n }\n}')
const environmentStatus = ref('not loaded')
const environmentVariables = ref<JsonObject>({})
const signatureInput = ref('hello')
const signatureSecret = ref('secret')
const signatureResult = ref('')
const advancedMode = ref('batch')
const advancedResult = ref('')
const selectedRequest = computed(() => {
return parsedRequests.value.find((request) => request.id === selectedRequestId.value) ?? parsedRequests.value[0]
})
const responseView = computed(() => {
const result = executionResult.value
if (!result) {
return {
status: 'No response yet',
duration: '',
headers: '',
body: 'Execute a request to see the response body.',
}
}
const nestedResult = (result.result ?? {}) as JsonObject
const response = (result.response ?? nestedResult.response ?? nestedResult ?? result) as JsonObject
const status = response.status ?? response.statusCode ?? result.status ?? 'unknown'
const duration = response.durationMs ?? result.durationMs ?? ''
const headers = response.headers ?? result.headers ?? {}
const body = response.body ?? result.body ?? result
return {
status: String(status),
duration: duration === '' ? '' : `${duration} ms`,
headers: JSON.stringify(headers, null, 2),
body: typeof body === 'string' ? body : JSON.stringify(body, null, 2),
}
})
const variablePreview = computed(() => {
return JSON.stringify(environmentVariables.value, null, 2)
})
onMounted(async () => {
await refreshHealth()
})
async function refreshHealth() {
try {
const health = await apiClient.health()
healthStatus.value = String((health.status ?? (health.data as JsonObject | undefined)?.status ?? 'ok') as string)
} catch (error) {
healthStatus.value = errorMessage(error)
}
}
async function parseContent() {
parseStatus.value = 'parsing'
try {
const parsed = await apiClient.parse({ content: httpContent.value, filePath: 'workspace.http' })
const requests = (parsed.requests ?? (parsed.data as JsonObject | undefined)?.requests ?? []) as ParsedRequest[]
parsedRequests.value = requests
selectedRequestId.value = requests[0]?.id ?? ''
parseStatus.value = `${requests.length} request(s)`
} catch (error) {
parseStatus.value = errorMessage(error)
}
}
async function executeSelected() {
const request = selectedRequest.value
if (!request) {
executionStatus.value = 'parse a request first'
return
}
executionStatus.value = 'running'
try {
const result = await apiClient.createExecution({
type: 'http',
request,
environment: environmentName.value,
})
executionResult.value = result
executionStatus.value = String(result.status ?? 'completed')
} catch (error) {
executionStatus.value = errorMessage(error)
}
}
async function loadEnvironment() {
environmentStatus.value = 'loading'
try {
const result = await apiClient.getEnvironments(workspace.value)
const rawEnvironments = result.environments as JsonObject | JsonObject[] | undefined
const defaultEnvironment = String(result.defaultEnvironment ?? 'local')
environmentName.value = defaultEnvironment
if (Array.isArray(rawEnvironments)) {
const active = rawEnvironments.find((entry) => entry.name === defaultEnvironment) ?? rawEnvironments[0]
environmentVariables.value = ((active?.variables ?? {}) as JsonObject)
} else {
environmentVariables.value = ((rawEnvironments?.[defaultEnvironment] ?? rawEnvironments ?? {}) as JsonObject)
}
environmentJson.value = JSON.stringify(rawEnvironments ?? { [defaultEnvironment]: environmentVariables.value }, null, 2)
environmentStatus.value = 'loaded'
} catch (error) {
environmentStatus.value = errorMessage(error)
}
}
async function saveEnvironment() {
environmentStatus.value = 'saving'
try {
const parsed = JSON.parse(environmentJson.value) as JsonObject
const environments = Array.isArray(parsed)
? parsed
: Object.entries(parsed).map(([name, variables]) => ({
name,
variables: (variables ?? {}) as JsonObject,
}))
await apiClient.saveEnvironments({
workspace: workspace.value,
environments: environments as unknown as JsonObject,
defaultEnvironment: environmentName.value || 'local',
})
environmentStatus.value = 'saved'
} catch (error) {
environmentStatus.value = errorMessage(error)
}
}
async function calculateSignature() {
signatureResult.value = 'calculating'
try {
const result = await apiClient.calculateSignature({
algorithm: 'hmac-sha256',
data: signatureInput.value,
secret: signatureSecret.value,
encoding: 'hex',
})
signatureResult.value = String(result.signature ?? result.value ?? JSON.stringify(result))
} catch (error) {
signatureResult.value = errorMessage(error)
}
}
async function runAdvanced() {
advancedResult.value = 'running'
try {
let result: JsonObject
if (advancedMode.value === 'mock') {
result = await apiClient.startMock({ workspace: workspace.value, port: 0 })
} else if (advancedMode.value === 'openapi') {
result = await apiClient.importOpenApi({
path: 'fixtures/workspaces/demo/openapi/demo.yaml',
baseUrlVariable: 'baseUrl',
})
} else if (advancedMode.value === 'history') {
result = await apiClient.getHistory()
} else if (advancedMode.value === 'events') {
result = await apiClient.getEvents()
} else if (advancedMode.value === 'curl') {
result = await apiClient.exportCurl({
request: selectedRequest.value ?? {
method: 'GET',
url: 'https://example.com',
headers: { Accept: 'application/json' },
},
})
} else if (advancedMode.value === 'postman') {
result = await apiClient.importPostman({
collection: {
item: [
{
name: 'Imported request',
request: {
method: 'GET',
url: 'https://example.com/api/users',
},
},
],
},
})
} else if (advancedMode.value === 'sse') {
result = await apiClient.createExecution({
type: 'sse',
request: selectedRequest.value ?? {
id: 'sse-demo',
name: 'SSE demo',
method: 'GET',
url: 'https://example.com/events',
headers: { Accept: 'text/event-stream' },
},
options: { maxEvents: 5 },
})
} else if (advancedMode.value === 'websocket') {
result = await apiClient.createExecution({
type: 'websocket',
request: {
id: 'ws-demo',
name: 'WebSocket demo',
method: 'GET',
url: 'ws://example.com/socket',
},
options: { messages: ['hello'] },
})
} else if (advancedMode.value === 'load-test') {
result = await apiClient.createExecution({
type: 'load-test',
request: selectedRequest.value,
options: { requests: 10, concurrency: 2 },
})
} else {
result = await apiClient.createExecution({
type: advancedMode.value,
requests: parsedRequests.value.length ? parsedRequests.value : [selectedRequest.value].filter(Boolean),
})
}
advancedResult.value = JSON.stringify(result, null, 2)
} catch (error) {
advancedResult.value = errorMessage(error)
}
}
function errorMessage(error: unknown) {
if (error instanceof ApiRequestError) {
return error.message
}
if (error instanceof Error) {
return error.message
}
return String(error)
}
</script>
<template>
<main class="app-shell">
<section class="hero" aria-labelledby="app-title">
<p class="eyebrow">HTTP Client MVP</p>
<h1 id="app-title">HTTP Client App</h1>
<p class="subtitle">用于管理工作区打开 .http 文件并预览请求调试流程的前端骨架</p>
</section>
<section class="status-card" aria-labelledby="backend-health-title">
<main class="workbench-shell">
<header class="topbar">
<div>
<p class="section-label">后端健康状态</p>
<h2 id="backend-health-title">等待接入健康检查</h2>
<p class="eyebrow">Local-first API laboratory</p>
<h1>HTTP Client Workbench</h1>
</div>
<span class="status-pill">占位</span>
<div class="health-card">
<span>Backend</span>
<strong data-testid="health-status">{{ healthStatus }}</strong>
</div>
</header>
<section class="workbench-grid" aria-label="HTTP client workbench">
<aside class="panel request-list-panel" data-testid="request-list-panel">
<div class="panel-heading">
<p class="section-label">Requests</p>
<span>{{ parseStatus }}</span>
</div>
<button type="button" data-testid="parse-button" class="primary-button" @click="parseContent">
Parse .http
</button>
<div data-testid="request-list" class="request-list">
<button
v-for="request in parsedRequests"
:key="request.id"
type="button"
class="request-row"
:class="{ active: request.id === selectedRequestId }"
@click="selectedRequestId = request.id"
>
<strong>{{ request.name || request.id }}</strong>
<span>{{ request.method }} {{ request.url }}</span>
</button>
<p v-if="parsedRequests.length === 0" class="muted">Parse the editor content to discover request blocks.</p>
</div>
</aside>
<section class="panel editor-panel" data-testid="editor-panel">
<div class="panel-heading">
<p class="section-label">.http Editor</p>
<button type="button" data-testid="execute-button" class="primary-button compact" @click="executeSelected">
Execute
</button>
</div>
<textarea v-model="httpContent" spellcheck="false" />
</section>
<section class="action-grid" aria-label="工作区入口">
<button type="button" class="action-card">
<span>打开工作区</span>
<small>选择包含请求集合的目录</small>
</button>
<button type="button" class="action-card">
<span>打开 .http 文件</span>
<small>从单个请求文件开始</small>
</button>
<section class="panel response-panel" data-testid="response-panel">
<div class="panel-heading">
<p class="section-label">Response</p>
<span>{{ executionStatus }}</span>
</div>
<div class="response-meta">
<strong>{{ responseView.status }}</strong>
<span>{{ responseView.duration }}</span>
</div>
<h2>Headers</h2>
<pre>{{ responseView.headers }}</pre>
<h2>Body</h2>
<pre>{{ responseView.body }}</pre>
</section>
</section>
<section class="lower-grid">
<section class="panel">
<div class="panel-heading">
<p class="section-label">Environment</p>
<span data-testid="environment-status">{{ environmentStatus }}</span>
</div>
<label>
Workspace
<input v-model="workspace" />
</label>
<label>
Active environment
<select v-model="environmentName" data-testid="environment-select">
<option value="">none</option>
<option value="local">local</option>
<option value="example">example</option>
</select>
</label>
<textarea v-model="environmentJson" class="short-editor" />
<div class="button-row">
<button type="button" data-testid="load-environment-button" @click="loadEnvironment">Load</button>
<button type="button" data-testid="save-environment-button" @click="saveEnvironment">Save</button>
</div>
<pre data-testid="variable-preview" class="preview">{{ variablePreview }}</pre>
</section>
<section class="panel">
<div class="panel-heading">
<p class="section-label">Signature</p>
<span>HMAC-SHA256</span>
</div>
<label>
Data
<input v-model="signatureInput" />
</label>
<label>
Secret
<input v-model="signatureSecret" />
</label>
<button type="button" data-testid="signature-button" @click="calculateSignature">Calculate signature</button>
<pre data-testid="signature-result" class="preview">{{ signatureResult }}</pre>
</section>
<section class="panel">
<div class="panel-heading">
<p class="section-label">Advanced execution</p>
<span>batch / chain / load / mock / OpenAPI</span>
</div>
<label>
Mode
<select v-model="advancedMode" data-testid="advanced-mode">
<option value="batch">batch</option>
<option value="chain">chain</option>
<option value="load-test">load-test</option>
<option value="sse">sse</option>
<option value="websocket">websocket</option>
<option value="mock">mock</option>
<option value="openapi">openapi</option>
<option value="history">history</option>
<option value="events">events</option>
<option value="curl">curl</option>
<option value="postman">postman</option>
</select>
</label>
<button type="button" data-testid="advanced-run" @click="runAdvanced">Run advanced action</button>
<pre data-testid="advanced-result" class="preview">{{ advancedResult }}</pre>
</section>
</section>
</main>
</template>
+212
View File
@@ -0,0 +1,212 @@
export type JsonObject = Record<string, unknown>
export interface ParsedRequest {
id: string
name: string
method: string
url: string
headers?: Record<string, string>
body?: string
[key: string]: unknown
}
export interface ExecutionPayload {
type: string
request?: ParsedRequest
requests?: ParsedRequest[]
filePath?: string
content?: string
environment?: string
options?: JsonObject
}
export interface EnvironmentPayload {
workspace: string
environments: JsonObject
defaultEnvironment: string
}
export interface SaveFilePayload {
path: string
content: string
baseHash?: string
}
export class ApiRequestError extends Error {
status: number
details: unknown
constructor(message: string, status: number, details: unknown) {
super(message)
this.name = 'ApiRequestError'
this.status = status
this.details = details
}
}
async function readBody(response: Response) {
const contentType = response.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
return response.json()
}
return response.text()
}
function buildErrorMessage(body: unknown, fallback: string) {
if (typeof body === 'string' && body.trim()) {
return body
}
if (body && typeof body === 'object') {
const objectBody = body as Record<string, unknown>
const message = objectBody.message ?? objectBody.error ?? objectBody.detail
if (typeof message === 'string' && message.trim()) {
return message
}
}
return fallback
}
async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(path, {
...init,
headers: {
Accept: 'application/json',
...(init?.body ? { 'Content-Type': 'application/json' } : {}),
...init?.headers,
},
})
const body = await readBody(response)
if (!response.ok) {
throw new ApiRequestError(buildErrorMessage(body, response.statusText), response.status, body)
}
if (body && typeof body === 'object' && 'success' in body && 'data' in body) {
const envelope = body as { success?: boolean; data?: unknown; error?: unknown }
if (envelope.success === false) {
throw new ApiRequestError(buildErrorMessage(envelope.error, response.statusText), response.status, envelope.error)
}
return envelope.data as T
}
return body as T
}
function query(params: Record<string, string>) {
return new URLSearchParams(params).toString()
}
export const apiClient = {
health() {
return requestJson<JsonObject>('/api/health')
},
parse(payload: { content: string; filePath?: string }) {
return requestJson<JsonObject>('/api/parse', {
method: 'POST',
body: JSON.stringify(payload),
})
},
listFiles(root: string) {
return requestJson<JsonObject>(`/api/files?${query({ root })}`)
},
readFile(path: string) {
return requestJson<JsonObject>('/api/files/read', {
method: 'POST',
body: JSON.stringify({ path }),
})
},
saveFile(payload: SaveFilePayload) {
return requestJson<JsonObject>('/api/files/save', {
method: 'POST',
body: JSON.stringify(payload),
})
},
getEnvironments(workspace: string) {
return requestJson<JsonObject>(`/api/environments?${query({ workspace })}`)
},
saveEnvironments(payload: EnvironmentPayload) {
return requestJson<JsonObject>('/api/environments', {
method: 'POST',
body: JSON.stringify(payload),
})
},
createExecution(payload: ExecutionPayload) {
return requestJson<JsonObject>('/api/executions', {
method: 'POST',
body: JSON.stringify(payload),
})
},
getExecution(id: string) {
return requestJson<JsonObject>(`/api/executions/${encodeURIComponent(id)}`)
},
cancelExecution(id: string) {
return requestJson<JsonObject>(`/api/executions/${encodeURIComponent(id)}/cancel`, {
method: 'POST',
})
},
getHistory() {
return requestJson<JsonObject>('/api/history')
},
getEvents() {
return requestJson<JsonObject>('/api/events')
},
calculateSignature(payload: JsonObject) {
return requestJson<JsonObject>('/api/signatures/calculate', {
method: 'POST',
body: JSON.stringify(payload),
})
},
startMock(payload: JsonObject) {
return requestJson<JsonObject>('/api/mock-server/start', {
method: 'POST',
body: JSON.stringify(payload),
})
},
getMockStatus() {
return requestJson<JsonObject>('/api/mock-server/status')
},
getMockHitLogs() {
return requestJson<JsonObject>('/api/mock-server/hit-logs')
},
importOpenApi(payload: JsonObject) {
return requestJson<JsonObject>('/api/openapi/import', {
method: 'POST',
body: JSON.stringify(payload),
})
},
exportCurl(payload: JsonObject) {
return requestJson<JsonObject>('/api/export/curl', {
method: 'POST',
body: JSON.stringify(payload),
})
},
importPostman(payload: JsonObject) {
return requestJson<JsonObject>('/api/import/postman', {
method: 'POST',
body: JSON.stringify(payload),
})
},
}
+224 -85
View File
@@ -1,6 +1,6 @@
:root {
color: #1f2a37;
background: #f3efe6;
color: #243126;
background: #ede4d2;
font-family: "Avenir Next", "Segoe UI", sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
@@ -11,40 +11,24 @@
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
radial-gradient(circle at top left, rgba(235, 176, 104, 0.32), transparent 34rem),
linear-gradient(135deg, #f8f4eb 0%, #e8dcc9 100%);
radial-gradient(circle at 12% 8%, rgba(237, 157, 84, 0.32), transparent 30rem),
radial-gradient(circle at 90% 14%, rgba(82, 118, 82, 0.18), transparent 28rem),
linear-gradient(135deg, #f7f0df 0%, #d9c7aa 100%);
}
button {
button,
input,
select,
textarea {
font: inherit;
}
.app-shell {
width: min(960px, calc(100% - 32px));
margin: 0 auto;
padding: 56px 0;
}
.hero {
padding: 48px;
border: 1px solid rgba(71, 51, 34, 0.14);
border-radius: 28px;
background: rgba(255, 252, 246, 0.76);
box-shadow: 0 24px 80px rgba(65, 47, 29, 0.14);
}
.eyebrow,
.section-label {
margin: 0 0 12px;
color: #9b4b24;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
button {
border: 0;
}
h1,
@@ -53,92 +37,247 @@ p {
margin-top: 0;
}
.workbench-shell {
width: min(1480px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 40px;
}
.topbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 20px;
padding: 28px;
border: 1px solid rgba(65, 47, 29, 0.14);
border-radius: 30px;
background: rgba(255, 252, 244, 0.72);
box-shadow: 0 24px 80px rgba(65, 47, 29, 0.14);
}
.eyebrow,
.section-label {
margin: 0 0 8px;
color: #9b4b24;
font-size: 0.74rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1 {
margin-bottom: 16px;
margin-bottom: 0;
color: #263326;
font-size: clamp(2.5rem, 8vw, 5.8rem);
font-size: clamp(2.3rem, 5vw, 5.2rem);
line-height: 0.94;
}
.subtitle {
max-width: 660px;
margin-bottom: 0;
color: #5f6a59;
font-size: 1.1rem;
line-height: 1.7;
.health-card {
min-width: 170px;
padding: 14px 18px;
border-radius: 20px;
color: #24442c;
background: #dce9cf;
}
.status-card,
.action-card {
border: 1px solid rgba(71, 51, 34, 0.12);
border-radius: 22px;
background: rgba(255, 252, 246, 0.7);
.health-card span,
.panel-heading span,
.muted {
color: #66705f;
font-size: 0.9rem;
}
.status-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
margin: 24px 0;
padding: 24px;
}
.status-card h2 {
margin-bottom: 0;
.health-card strong {
display: block;
margin-top: 4px;
font-size: 1.25rem;
}
.status-pill {
padding: 8px 14px;
border-radius: 999px;
color: #6d3b19;
background: #f3d9b8;
font-weight: 700;
}
.action-grid {
.workbench-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(220px, 0.8fr) minmax(360px, 1.35fr) minmax(320px, 1fr);
gap: 16px;
}
.action-card {
min-height: 132px;
padding: 24px;
color: #263326;
text-align: left;
cursor: not-allowed;
.lower-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.action-card span {
display: block;
margin-bottom: 12px;
font-size: 1.25rem;
.panel {
overflow: hidden;
min-height: 240px;
padding: 18px;
border: 1px solid rgba(65, 47, 29, 0.12);
border-radius: 24px;
background: rgba(255, 252, 244, 0.78);
box-shadow: 0 18px 48px rgba(65, 47, 29, 0.1);
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
}
.primary-button,
.panel button {
cursor: pointer;
border-radius: 999px;
color: #fff9eb;
background: #87461f;
box-shadow: 0 10px 22px rgba(135, 70, 31, 0.2);
}
.primary-button {
width: 100%;
padding: 12px 14px;
font-weight: 800;
}
.action-card small {
color: #66705f;
font-size: 0.95rem;
.primary-button.compact,
.panel button {
width: auto;
padding: 9px 13px;
}
@media (max-width: 640px) {
.app-shell {
padding: 24px 0;
}
.request-list {
display: grid;
gap: 10px;
margin-top: 14px;
}
.hero {
padding: 28px;
}
.request-row {
display: grid;
gap: 4px;
width: 100%;
padding: 13px;
border: 1px solid rgba(65, 47, 29, 0.12);
border-radius: 16px;
color: #263326;
text-align: left;
background: rgba(255, 255, 255, 0.52);
cursor: pointer;
}
.status-card,
.action-grid {
.request-row.active {
border-color: rgba(135, 70, 31, 0.42);
background: #f2dac0;
}
.request-row span {
overflow: hidden;
color: #66705f;
font-size: 0.84rem;
text-overflow: ellipsis;
white-space: nowrap;
}
textarea,
input,
select {
width: 100%;
border: 1px solid rgba(65, 47, 29, 0.16);
border-radius: 16px;
color: #263326;
background: rgba(255, 255, 255, 0.72);
}
textarea {
min-height: 440px;
padding: 16px;
resize: vertical;
font-family: "Cascadia Code", "JetBrains Mono", monospace;
line-height: 1.55;
}
.short-editor {
min-height: 132px;
}
input,
select {
height: 42px;
margin-top: 6px;
padding: 0 12px;
}
label {
display: block;
margin-bottom: 12px;
color: #5f6a59;
font-size: 0.9rem;
font-weight: 700;
}
.response-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding: 12px;
border-radius: 16px;
background: #e6efd9;
}
.response-meta strong {
color: #24442c;
font-size: 1.5rem;
}
.response-panel h2 {
margin: 16px 0 8px;
font-size: 1rem;
}
pre {
overflow: auto;
max-height: 280px;
margin: 0;
padding: 12px;
border-radius: 16px;
color: #263326;
background: rgba(35, 43, 38, 0.08);
font-family: "Cascadia Code", "JetBrains Mono", monospace;
font-size: 0.86rem;
}
.preview {
margin-top: 12px;
}
.button-row {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
@media (max-width: 1120px) {
.workbench-grid,
.lower-grid {
grid-template-columns: 1fr;
}
.status-card {
textarea {
min-height: 280px;
}
}
@media (max-width: 640px) {
.workbench-shell {
width: min(100% - 18px, 1480px);
padding-top: 12px;
}
.topbar {
align-items: flex-start;
flex-direction: column;
padding: 22px;
}
}
+90
View File
@@ -0,0 +1,90 @@
param(
[switch]$SkipAudit
)
$ErrorActionPreference = "Stop"
$root = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
function Invoke-Step {
param(
[string]$Name,
[scriptblock]$Command
)
Write-Host ""
Write-Host "==> $Name"
$global:LASTEXITCODE = 0
& $Command
if ($LASTEXITCODE -ne 0) {
throw "$Name failed with exit code $LASTEXITCODE."
}
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath failed with exit code $LASTEXITCODE."
}
}
Invoke-Step "Backend tests" {
Push-Location (Join-Path $root "backend")
try {
go test ./...
}
finally {
Pop-Location
}
}
Invoke-Step "Frontend tests" {
Push-Location (Join-Path $root "frontend")
try {
npm test
}
finally {
Pop-Location
}
}
Invoke-Step "Frontend production build" {
Push-Location (Join-Path $root "frontend")
try {
npm run build
}
finally {
Pop-Location
}
}
Invoke-Step "Documentation verification" {
Invoke-External "powershell" @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $root "scripts\verify-docs.ps1"))
}
Invoke-Step "API contract verification" {
Invoke-External "powershell" @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $root "scripts\verify-api-contract.ps1"))
}
Invoke-Step "Backend smoke verification" {
Invoke-External "powershell" @("-NoProfile", "-ExecutionPolicy", "Bypass", "-File", (Join-Path $root "scripts\verify-backend-smoke.ps1"))
}
if (-not $SkipAudit) {
Invoke-Step "Frontend dependency audit" {
Push-Location (Join-Path $root "frontend")
try {
npm audit --audit-level=high
}
finally {
Pop-Location
}
}
}
Write-Host ""
Write-Host "All verification steps passed."
+50
View File
@@ -0,0 +1,50 @@
param(
[string]$RouterPath = (Join-Path $PSScriptRoot "..\backend\internal\api\router.go"),
[string]$ContractPath = (Join-Path $PSScriptRoot "..\contracts\app-api.openapi.yaml")
)
$ErrorActionPreference = "Stop"
$router = Get-Content -Raw -LiteralPath (Resolve-Path -LiteralPath $RouterPath)
$contract = Get-Content -Raw -LiteralPath (Resolve-Path -LiteralPath $ContractPath)
function Normalize-Path {
param([string]$Path)
return ($Path -replace ":id", "{executionId}")
}
$routePattern = 'router\.(GET|POST|PUT|PATCH|DELETE)\("([^"]+)"'
$routes = [regex]::Matches($router, $routePattern) | ForEach-Object {
$method = $_.Groups[1].Value.ToLowerInvariant()
$path = Normalize-Path $_.Groups[2].Value
"$method $path"
} | Sort-Object -Unique
$pathMatches = [regex]::Matches($contract, '(?m)^ (/api/[^:]+):\s*$')
$operations = New-Object System.Collections.Generic.List[string]
for ($index = 0; $index -lt $pathMatches.Count; $index++) {
$path = $pathMatches[$index].Groups[1].Value
$start = $pathMatches[$index].Index + $pathMatches[$index].Length
$end = if ($index + 1 -lt $pathMatches.Count) { $pathMatches[$index + 1].Index } else { $contract.Length }
$block = $contract.Substring($start, $end - $start)
[regex]::Matches($block, '(?m)^ (get|post|put|patch|delete):\s*$') | ForEach-Object {
$operations.Add("$($_.Groups[1].Value) $path")
}
}
$contractOperations = $operations | Sort-Object -Unique
$missing = Compare-Object -ReferenceObject $routes -DifferenceObject $contractOperations |
Where-Object { $_.SideIndicator -eq "<=" } |
Select-Object -ExpandProperty InputObject
if ($missing) {
Write-Host "OpenAPI contract is missing backend route(s):"
foreach ($route in $missing) {
Write-Host " - $route"
}
exit 1
}
Write-Host "OpenAPI contract covers all backend routes."
+366
View File
@@ -0,0 +1,366 @@
param(
[int]$ApiPort = 0,
[int]$TargetPort = 0,
[int]$StartupTimeoutSeconds = 30
)
$ErrorActionPreference = "Stop"
$root = Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")
$backend = Join-Path $root "backend"
$logDir = Join-Path $root "http-client-logs"
$workspace = Join-Path $root "fixtures\workspaces\demo"
$openApiPath = Join-Path $workspace "openapi\demo.yaml"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Parse("127.0.0.1"), 0)
try {
$listener.Start()
return ([System.Net.IPEndPoint]$listener.LocalEndpoint).Port
}
finally {
$listener.Stop()
}
}
function Test-PortAvailable {
param([int]$Port)
$existing = Get-NetTCPConnection -LocalPort $Port -ErrorAction SilentlyContinue |
Where-Object { $_.State -eq "Listen" } |
Select-Object -First 1
if ($existing) {
throw "Smoke port $Port is already in use by process $($existing.OwningProcess)."
}
}
function Invoke-External {
param(
[string]$FilePath,
[string[]]$Arguments,
[string]$WorkingDirectory = $PWD.Path
)
Push-Location $WorkingDirectory
try {
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath failed with exit code $LASTEXITCODE."
}
}
finally {
Pop-Location
}
}
function Invoke-Json {
param(
[string]$Method,
[string]$Uri,
[object]$Body = $null
)
$params = @{
Method = $Method
Uri = $Uri
TimeoutSec = 10
}
if ($null -ne $Body) {
$params.ContentType = "application/json"
$params.Body = ($Body | ConvertTo-Json -Depth 50)
}
Invoke-RestMethod @params
}
function Wait-HttpOk {
param(
[string]$Uri,
[int]$TimeoutSeconds
)
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
do {
try {
$response = Invoke-WebRequest -Uri $Uri -UseBasicParsing -TimeoutSec 2
if ($response.StatusCode -ge 200 -and $response.StatusCode -lt 500) {
return
}
}
catch {
Start-Sleep -Milliseconds 250
}
} while ((Get-Date) -lt $deadline)
throw "Timed out waiting for $Uri"
}
function Assert-True {
param(
[bool]$Condition,
[string]$Message
)
if (-not $Condition) {
throw $Message
}
}
$ApiPort = if ($ApiPort -eq 0) { Get-FreeTcpPort } else { $ApiPort }
$TargetPort = if ($TargetPort -eq 0) { Get-FreeTcpPort } else { $TargetPort }
$targetSource = Join-Path $backend ("smoke-target-{0}.go" -f ([System.Guid]::NewGuid().ToString("N")))
$targetExe = Join-Path ([System.IO.Path]::GetTempPath()) ("http-client-target-{0}.exe" -f ([System.Guid]::NewGuid().ToString("N")))
$apiExe = Join-Path ([System.IO.Path]::GetTempPath()) ("http-client-api-{0}.exe" -f ([System.Guid]::NewGuid().ToString("N")))
$targetCode = @"
package main
import (
"encoding/json"
"net/http"
"strings"
"time"
"golang.org/x/net/websocket"
)
func writeJSON(w http.ResponseWriter, status int, payload any) {
body, _ := json.Marshal(payload)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write(body)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
body := []byte("event: message\ndata: hello\n\n")
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
})
mux.HandleFunc("/api/users", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]any{
"users": []map[string]any{{"id": 42, "name": "Zoe"}},
"path": r.URL.RequestURI(),
})
case http.MethodPost:
var body any
_ = json.NewDecoder(r.Body).Decode(&body)
writeJSON(w, http.StatusCreated, map[string]any{"created": true, "body": body})
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
})
mux.HandleFunc("/api/private/", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/private/") {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, map[string]any{"private": true, "path": r.URL.Path})
})
mux.Handle("/socket", websocket.Handler(func(conn *websocket.Conn) {
defer conn.Close()
for {
var message string
if err := websocket.Message.Receive(conn, &message); err != nil {
return
}
if err := websocket.Message.Send(conn, "echo:"+message); err != nil {
return
}
}
}))
server := &http.Server{
Addr: "127.0.0.1:$TargetPort",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
panic(err)
}
}
"@
$apiProcess = $null
$targetProcess = $null
try {
Test-PortAvailable -Port $ApiPort
Test-PortAvailable -Port $TargetPort
Invoke-External -FilePath "go" -Arguments @("build", "-o", $apiExe, "./cmd/api-client") -WorkingDirectory $backend
Set-Content -LiteralPath $targetSource -Value $targetCode -Encoding UTF8
Invoke-External -FilePath "go" -Arguments @("build", "-o", $targetExe, $targetSource) -WorkingDirectory $backend
$targetProcess = Start-Process -FilePath $targetExe -PassThru -WindowStyle Hidden `
-RedirectStandardOutput (Join-Path $logDir "smoke-target.out.log") `
-RedirectStandardError (Join-Path $logDir "smoke-target.err.log")
$previousAddr = $env:HTTP_CLIENT_ADDR
$env:HTTP_CLIENT_ADDR = "127.0.0.1:$ApiPort"
try {
$apiProcess = Start-Process -FilePath $apiExe -WorkingDirectory $backend -PassThru -WindowStyle Hidden `
-RedirectStandardOutput (Join-Path $logDir "smoke-backend.out.log") `
-RedirectStandardError (Join-Path $logDir "smoke-backend.err.log")
}
finally {
$env:HTTP_CLIENT_ADDR = $previousAddr
}
Wait-HttpOk -Uri "http://127.0.0.1:$TargetPort/api/users" -TimeoutSeconds $StartupTimeoutSeconds
Wait-HttpOk -Uri "http://127.0.0.1:$ApiPort/api/health" -TimeoutSeconds $StartupTimeoutSeconds
$health = Invoke-Json -Method "GET" -Uri "http://127.0.0.1:$ApiPort/api/health"
Assert-True ($health.success -eq $true) "Health check did not return success=true."
$httpContent = @"
@baseUrl = http://127.0.0.1:$TargetPort
# @name listUsers
GET {{baseUrl}}/api/users?limit=2
Accept: application/json
###
# @name createUser
POST {{baseUrl}}/api/users
Content-Type: application/json
{"name":"Zoe"}
"@
$parsed = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/parse" -Body @{
content = $httpContent
filePath = "smoke.http"
}
Assert-True ($parsed.success -eq $true) "Parse endpoint did not return success=true."
Assert-True ($parsed.data.requests.Count -ge 2) "Parse endpoint did not find two requests."
$signature = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/signatures/calculate" -Body @{
algorithm = "hmac-sha256"
data = "hello"
secret = "secret"
encoding = "hex"
}
Assert-True ($signature.data.value -eq "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b") "Unexpected HMAC-SHA256 output."
$execution = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/executions" -Body @{
type = "http"
request = @{
method = "GET"
url = "http://127.0.0.1:$TargetPort/api/users?limit=2"
headers = @{ Accept = "application/json" }
}
}
Assert-True ($execution.success -eq $true) "HTTP execution did not return success=true."
Assert-True ($execution.data.status -eq "succeeded") "HTTP execution did not succeed."
Assert-True ([int]$execution.data.result.statusCode -eq 200) "HTTP execution did not return target status 200."
$batch = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/executions" -Body @{
type = "batch"
requests = @(
@{ method = "GET"; url = "http://127.0.0.1:$TargetPort/api/users" },
@{ method = "POST"; url = "http://127.0.0.1:$TargetPort/api/users"; headers = @{ "Content-Type" = "application/json" }; body = '{"name":"Batch"}' }
)
}
Assert-True ($batch.data.status -eq "succeeded") "Batch execution did not succeed."
Assert-True ($batch.data.children.Count -eq 2) "Batch execution did not return two children."
$load = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/executions" -Body @{
type = "load-test"
request = @{ method = "GET"; url = "http://127.0.0.1:$TargetPort/api/users" }
options = @{ requests = 5; concurrency = 2 }
}
Assert-True ($load.data.status -eq "succeeded") "Load-test execution did not succeed."
$sse = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/executions" -Body @{
type = "sse"
request = @{ method = "GET"; url = "http://127.0.0.1:$TargetPort/events" }
options = @{ maxEvents = 1 }
}
Assert-True ($sse.data.status -eq "succeeded") "SSE execution did not succeed."
Assert-True ($sse.data.result.events.Count -ge 1) "SSE execution did not capture events."
$websocket = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/executions" -Body @{
type = "websocket"
request = @{ method = "GET"; url = "ws://127.0.0.1:$TargetPort/socket" }
options = @{ messages = @("hello") }
}
Assert-True ($websocket.data.status -eq "succeeded") "WebSocket execution did not produce a session summary."
Assert-True ($websocket.data.result.received[0] -eq "echo:hello") "WebSocket execution did not exchange messages with target server."
$history = Invoke-Json -Method "GET" -Uri "http://127.0.0.1:$ApiPort/api/history"
Assert-True ($history.data.items.Count -ge 1) "History endpoint did not return executions."
$events = Invoke-Json -Method "GET" -Uri "http://127.0.0.1:$ApiPort/api/events"
Assert-True ($events.data.events.Count -ge 1) "Global events endpoint did not return events."
$curl = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/export/curl" -Body @{
request = @{
method = "GET"
url = "http://127.0.0.1:$TargetPort/api/users"
headers = @{ Accept = "application/json" }
}
}
Assert-True ($curl.data.curl -match "curl -X GET") "curl export did not generate a GET command."
$postman = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/import/postman" -Body @{
collection = @{
item = @(
@{
name = "Imported users"
request = @{
method = "GET"
url = "http://127.0.0.1:$TargetPort/api/users"
}
}
)
}
}
Assert-True ($postman.data.requests -match "Imported users") "Postman import did not generate .http content."
$openapi = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/openapi/import" -Body @{
path = $openApiPath
baseUrlVariable = "baseUrl"
}
Assert-True ($openapi.success -eq $true) "OpenAPI import did not return success=true."
Assert-True ($openapi.data.requests -match "listUsers") "OpenAPI import did not generate listUsers template."
$mockStatus = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/mock-server/start" -Body @{
workspace = $workspace
port = 0
}
Assert-True ($mockStatus.data.running -eq $true) "Mock server did not start."
$mockResponse = Invoke-WebRequest -Uri "$($mockStatus.data.baseUrl)/api/users" -UseBasicParsing -TimeoutSec 10
Assert-True ($mockResponse.StatusCode -eq 200) "Mock server did not serve the demo rule."
$mockLogs = Invoke-Json -Method "GET" -Uri "http://127.0.0.1:$ApiPort/api/mock-server/hit-logs"
Assert-True ($mockLogs.data.logs.Count -ge 1) "Mock hit logs did not record the request."
$null = Invoke-Json -Method "POST" -Uri "http://127.0.0.1:$ApiPort/api/mock-server/stop"
Write-Host "Backend smoke verification passed."
}
finally {
if ($apiProcess -and -not $apiProcess.HasExited) {
Stop-Process -Id $apiProcess.Id -Force -ErrorAction SilentlyContinue
Wait-Process -Id $apiProcess.Id -Timeout 5 -ErrorAction SilentlyContinue
}
if ($targetProcess -and -not $targetProcess.HasExited) {
Stop-Process -Id $targetProcess.Id -Force -ErrorAction SilentlyContinue
Wait-Process -Id $targetProcess.Id -Timeout 5 -ErrorAction SilentlyContinue
}
if (Test-Path -LiteralPath $targetSource) {
Remove-Item -LiteralPath $targetSource -Force
}
if (Test-Path -LiteralPath $targetExe) {
Remove-Item -LiteralPath $targetExe -Force -ErrorAction SilentlyContinue
}
if (Test-Path -LiteralPath $apiExe) {
Remove-Item -LiteralPath $apiExe -Force -ErrorAction SilentlyContinue
}
}