feat: implement http client app MVP
This commit is contained in:
@@ -4,14 +4,20 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/local/http-client-app/backend/internal/api"
|
"github.com/local/http-client-app/backend/internal/api"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
addr := os.Getenv("HTTP_CLIENT_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = "127.0.0.1:32180"
|
||||||
|
}
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: "127.0.0.1:32180",
|
Addr: addr,
|
||||||
Handler: api.NewRouter(),
|
Handler: api.NewRouter(),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-3
@@ -2,7 +2,11 @@ module github.com/local/http-client-app/backend
|
|||||||
|
|
||||||
go 1.26.0
|
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 (
|
require (
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
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/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // 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/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.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/arch v0.20.0 // indirect
|
||||||
golang.org/x/crypto v0.40.0 // indirect
|
golang.org/x/crypto v0.40.0 // indirect
|
||||||
golang.org/x/mod v0.25.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/sync v0.16.0 // indirect
|
||||||
golang.org/x/sys v0.35.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.27.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
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/local/http-client-app/backend/internal/api"
|
"github.com/local/http-client-app/backend/internal/api"
|
||||||
|
"golang.org/x/net/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHealthEndpointReturnsEnvelope(t *testing.T) {
|
func TestHealthEndpointReturnsEnvelope(t *testing.T) {
|
||||||
@@ -51,3 +54,223 @@ func TestHealthEndpointReturnsEnvelope(t *testing.T) {
|
|||||||
t.Fatalf("expected timestamp to be set")
|
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),
|
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 { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import App from './App.vue'
|
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', () => {
|
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)
|
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>
|
<template>
|
||||||
<main class="app-shell">
|
<main class="workbench-shell">
|
||||||
<section class="hero" aria-labelledby="app-title">
|
<header class="topbar">
|
||||||
<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">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="section-label">后端健康状态</p>
|
<p class="eyebrow">Local-first API laboratory</p>
|
||||||
<h2 id="backend-health-title">等待接入健康检查</h2>
|
<h1>HTTP Client Workbench</h1>
|
||||||
</div>
|
</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="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>
|
||||||
|
|
||||||
<section class="action-grid" aria-label="工作区入口">
|
<section class="lower-grid">
|
||||||
<button type="button" class="action-card">
|
<section class="panel">
|
||||||
<span>打开工作区</span>
|
<div class="panel-heading">
|
||||||
<small>选择包含请求集合的目录</small>
|
<p class="section-label">Environment</p>
|
||||||
</button>
|
<span data-testid="environment-status">{{ environmentStatus }}</span>
|
||||||
<button type="button" class="action-card">
|
</div>
|
||||||
<span>打开 .http 文件</span>
|
<label>
|
||||||
<small>从单个请求文件开始</small>
|
Workspace
|
||||||
</button>
|
<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>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</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 {
|
:root {
|
||||||
color: #1f2a37;
|
color: #243126;
|
||||||
background: #f3efe6;
|
background: #ede4d2;
|
||||||
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
font-family: "Avenir Next", "Segoe UI", sans-serif;
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -11,40 +11,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top left, rgba(235, 176, 104, 0.32), transparent 34rem),
|
radial-gradient(circle at 12% 8%, rgba(237, 157, 84, 0.32), transparent 30rem),
|
||||||
linear-gradient(135deg, #f8f4eb 0%, #e8dcc9 100%);
|
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;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
button {
|
||||||
width: min(960px, calc(100% - 32px));
|
border: 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
@@ -53,92 +37,247 @@ p {
|
|||||||
margin-top: 0;
|
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 {
|
h1 {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 0;
|
||||||
color: #263326;
|
color: #263326;
|
||||||
font-size: clamp(2.5rem, 8vw, 5.8rem);
|
font-size: clamp(2.3rem, 5vw, 5.2rem);
|
||||||
line-height: 0.94;
|
line-height: 0.94;
|
||||||
}
|
}
|
||||||
|
|
||||||
.subtitle {
|
.health-card {
|
||||||
max-width: 660px;
|
min-width: 170px;
|
||||||
margin-bottom: 0;
|
padding: 14px 18px;
|
||||||
color: #5f6a59;
|
border-radius: 20px;
|
||||||
font-size: 1.1rem;
|
color: #24442c;
|
||||||
line-height: 1.7;
|
background: #dce9cf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card,
|
.health-card span,
|
||||||
.action-card {
|
.panel-heading span,
|
||||||
border: 1px solid rgba(71, 51, 34, 0.12);
|
.muted {
|
||||||
border-radius: 22px;
|
color: #66705f;
|
||||||
background: rgba(255, 252, 246, 0.7);
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.health-card strong {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
margin-top: 4px;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 24px;
|
|
||||||
margin: 24px 0;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-card h2 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-pill {
|
.workbench-grid {
|
||||||
padding: 8px 14px;
|
|
||||||
border-radius: 999px;
|
|
||||||
color: #6d3b19;
|
|
||||||
background: #f3d9b8;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-grid {
|
|
||||||
display: 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;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card {
|
.lower-grid {
|
||||||
min-height: 132px;
|
display: grid;
|
||||||
padding: 24px;
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
color: #263326;
|
gap: 16px;
|
||||||
text-align: left;
|
margin-top: 16px;
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card span {
|
.panel {
|
||||||
display: block;
|
overflow: hidden;
|
||||||
margin-bottom: 12px;
|
min-height: 240px;
|
||||||
font-size: 1.25rem;
|
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;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card small {
|
.primary-button.compact,
|
||||||
color: #66705f;
|
.panel button {
|
||||||
font-size: 0.95rem;
|
width: auto;
|
||||||
|
padding: 9px 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.request-list {
|
||||||
.app-shell {
|
display: grid;
|
||||||
padding: 24px 0;
|
gap: 10px;
|
||||||
}
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.hero {
|
.request-row {
|
||||||
padding: 28px;
|
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,
|
.request-row.active {
|
||||||
.action-grid {
|
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;
|
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;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
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