feat: implement http client app MVP
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
+1131
-5
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+1292
-37
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.
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
@@ -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."
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user