diff --git a/backend/cmd/api-client/main.go b/backend/cmd/api-client/main.go index c0bc1e5..8d5d256 100644 --- a/backend/cmd/api-client/main.go +++ b/backend/cmd/api-client/main.go @@ -4,14 +4,20 @@ import ( "errors" "log" "net/http" + "os" "time" "github.com/local/http-client-app/backend/internal/api" ) func main() { + addr := os.Getenv("HTTP_CLIENT_ADDR") + if addr == "" { + addr = "127.0.0.1:32180" + } + server := &http.Server{ - Addr: "127.0.0.1:32180", + Addr: addr, Handler: api.NewRouter(), ReadHeaderTimeout: 5 * time.Second, } diff --git a/backend/go.mod b/backend/go.mod index 204e20e..6469e12 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -2,7 +2,11 @@ module github.com/local/http-client-app/backend go 1.26.0 -require github.com/gin-gonic/gin v1.11.0 +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/goccy/go-yaml v1.18.0 + golang.org/x/net v0.42.0 +) require ( github.com/bytedance/sonic v1.14.0 // indirect @@ -14,7 +18,6 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.2 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -30,7 +33,6 @@ require ( golang.org/x/arch v0.20.0 // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.35.0 // indirect golang.org/x/text v0.27.0 // indirect diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 549c38e..b279e0f 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,27 +1,558 @@ package api import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io/fs" + "net" "net/http" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" "github.com/gin-gonic/gin" + "github.com/goccy/go-yaml" + "github.com/local/http-client-app/backend/internal/execution" "github.com/local/http-client-app/backend/internal/model" + "github.com/local/http-client-app/backend/internal/parser" + "github.com/local/http-client-app/backend/internal/signature" + "golang.org/x/net/websocket" ) +const appVersion = "0.2.0" + +type Server struct { + executions *execution.Store + mock *mockRuntime +} + func NewRouter() http.Handler { + return NewServer().Router() +} + +func NewServer() *Server { + return &Server{ + executions: execution.NewStore(), + mock: newMockRuntime(), + } +} + +func (s *Server) Router() http.Handler { gin.SetMode(gin.ReleaseMode) router := gin.New() router.Use(gin.Recovery()) - router.GET("/api/health", func(c *gin.Context) { - c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ - "status": "ok", - })) - }) + router.GET("/api/health", s.health) + router.POST("/api/parse", s.parseHTTP) + router.GET("/api/files", s.listFiles) + router.POST("/api/files/read", s.readFile) + router.POST("/api/files/save", s.saveFile) + router.GET("/api/environments", s.getEnvironments) + router.POST("/api/environments", s.saveEnvironments) + router.POST("/api/executions", s.createExecution) + router.GET("/api/executions/:id", s.getExecution) + router.POST("/api/executions/:id/cancel", s.cancelExecution) + router.GET("/api/executions/:id/events", s.getExecutionEvents) + router.GET("/api/history", s.history) + router.GET("/api/events", s.globalEvents) + router.GET("/api/events/sse", s.sseEvents) + router.GET("/api/events/ws", s.websocketEvents) + router.POST("/api/signatures/calculate", s.calculateSignature) + router.GET("/api/mocks", s.listMocks) + router.POST("/api/mocks", s.saveMocks) + router.POST("/api/mock-files/save", s.saveMocks) + router.POST("/api/mock-files/reload", s.reloadMocks) + router.POST("/api/mock-files/preview", s.previewMocks) + router.POST("/api/mock-server/start", s.startMockServer) + router.POST("/api/mock-server/stop", s.stopMockServer) + router.GET("/api/mock-server/status", s.mockServerStatus) + router.GET("/api/mock-server/hit-logs", s.mockHitLogs) + router.DELETE("/api/mock-server/hit-logs", s.clearMockHitLogs) + router.POST("/api/mock/start", s.startMockServer) + router.POST("/api/openapi/import", s.importOpenAPI) + router.POST("/api/openapi/sync/preview", s.importOpenAPI) + router.POST("/api/openapi/diff", s.openapiDiff) + router.POST("/api/openapi/validate-response", s.validateOpenAPIResponse) + router.POST("/api/export/curl", s.exportCurl) + router.POST("/api/import/curl", s.importCurl) + router.POST("/api/import/postman", s.importPostman) + router.POST("/api/indexes/rebuild", s.rebuildIndexes) return router } +func (s *Server) health(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "status": "ok", + "version": appVersion, + })) +} + +func (s *Server) parseHTTP(c *gin.Context) { + var request struct { + Content string `json:"content"` + FilePath string `json:"filePath"` + } + if !bindJSON(c, &request) { + return + } + + parsed := parser.Parse(request.Content, request.FilePath) + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), parsed)) +} + +func (s *Server) listFiles(c *gin.Context) { + root := c.Query("root") + if root == "" { + root = "." + } + root = filepath.Clean(root) + + var files []gin.H + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + if path == root { + return nil + } + name := entry.Name() + if entry.IsDir() && (name == ".git" || name == "node_modules" || name == "dist") { + return filepath.SkipDir + } + if entry.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".http" && ext != ".rest" && ext != ".json" && ext != ".yaml" && ext != ".yml" { + return nil + } + info, _ := entry.Info() + files = append(files, gin.H{ + "path": path, + "name": name, + "sizeBytes": sizeOf(info), + "modifiedAt": modifiedAt(info), + }) + return nil + }) + if err != nil { + respondError(c, http.StatusBadRequest, "FILE_LIST_FAILED", err.Error(), nil) + return + } + + sort.Slice(files, func(i, j int) bool { + return fmt.Sprint(files[i]["path"]) < fmt.Sprint(files[j]["path"]) + }) + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{"root": root, "files": files})) +} + +func (s *Server) readFile(c *gin.Context) { + var request struct { + Path string `json:"path"` + } + if !bindJSON(c, &request) { + return + } + + content, err := os.ReadFile(request.Path) + if err != nil { + respondError(c, http.StatusBadRequest, "FILE_READ_FAILED", err.Error(), nil) + return + } + + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "path": request.Path, + "content": string(strings.TrimPrefix(string(content), "\ufeff")), + "contentHash": contentHash(content), + })) +} + +func (s *Server) saveFile(c *gin.Context) { + var request struct { + Path string `json:"path"` + Content string `json:"content"` + BaseHash string `json:"baseHash"` + } + if !bindJSON(c, &request) { + return + } + + if request.BaseHash != "" { + current, err := os.ReadFile(request.Path) + if err == nil && contentHash(current) != request.BaseHash { + respondError(c, http.StatusConflict, "FILE_CONFLICT", "file was modified outside the app", nil) + return + } + } + + if err := atomicWrite(request.Path, []byte(request.Content)); err != nil { + respondError(c, http.StatusBadRequest, "FILE_SAVE_FAILED", err.Error(), nil) + return + } + + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "path": request.Path, + "contentHash": contentHash([]byte(request.Content)), + })) +} + +func (s *Server) getEnvironments(c *gin.Context) { + workspace := c.Query("workspace") + doc, err := readEnvironmentDocument(workspace) + if err != nil { + respondError(c, http.StatusBadRequest, "ENVIRONMENT_READ_FAILED", err.Error(), nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), doc)) +} + +func (s *Server) saveEnvironments(c *gin.Context) { + var doc environmentDocument + if !bindJSON(c, &doc) { + return + } + if doc.SchemaVersion == 0 { + doc.SchemaVersion = 1 + } + if doc.Environments == nil { + doc.Environments = []environmentEntry{} + } + if doc.Globals == nil { + doc.Globals = map[string]any{} + } + if doc.Workspace != "" { + body, _ := json.MarshalIndent(doc, "", " ") + if err := atomicWrite(environmentPath(doc.Workspace), append(body, '\n')); err != nil { + respondError(c, http.StatusBadRequest, "ENVIRONMENT_SAVE_FAILED", err.Error(), nil) + return + } + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), doc)) +} + +func (s *Server) createExecution(c *gin.Context) { + var request execution.CreateRequest + if !bindJSON(c, &request) { + return + } + + if request.Request == nil && request.Content != "" { + parsed := parser.Parse(request.Content, request.FilePath) + if len(parsed.Requests) > 0 { + envVars := map[string]string{} + if request.Environment != "" && request.FilePath != "" { + envVars = environmentVariablesFor(filepath.Dir(request.FilePath), request.Environment) + } + resolved, err := parser.ResolveRequest(parsed.Requests[0], parsed.Variables, envVars) + if err != nil { + respondError(c, http.StatusBadRequest, "VARIABLE_RESOLUTION_FAILED", err.Error(), nil) + return + } + request.Request = &execution.RequestItem{ + Name: resolved.Name, + Method: resolved.Method, + URL: resolved.URL, + Headers: resolved.Headers, + Body: resolved.Body, + } + } + } + + result := s.executions.Create(request) + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), result)) +} + +func (s *Server) getExecution(c *gin.Context) { + result, ok := s.executions.Get(c.Param("id")) + if !ok { + respondError(c, http.StatusNotFound, "EXECUTION_NOT_FOUND", "execution not found", nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), result)) +} + +func (s *Server) cancelExecution(c *gin.Context) { + result, ok := s.executions.Cancel(c.Param("id")) + if !ok { + respondError(c, http.StatusNotFound, "EXECUTION_NOT_FOUND", "execution not found", nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), result)) +} + +func (s *Server) getExecutionEvents(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "events": s.executions.Events(c.Param("id")), + })) +} + +func (s *Server) history(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "items": s.executions.History(100), + })) +} + +func (s *Server) globalEvents(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "events": s.executions.AllEvents(), + })) +} + +func (s *Server) sseEvents(c *gin.Context) { + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + for _, event := range s.executions.AllEvents() { + payload, _ := json.Marshal(event) + _, _ = fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event.Type, payload) + } +} + +func (s *Server) websocketEvents(c *gin.Context) { + websocket.Handler(func(conn *websocket.Conn) { + defer conn.Close() + _ = json.NewEncoder(conn).Encode(gin.H{"events": s.executions.AllEvents()}) + }).ServeHTTP(c.Writer, c.Request) +} + +func (s *Server) calculateSignature(c *gin.Context) { + var request signature.Request + if !bindJSON(c, &request) { + return + } + + result, err := signature.Calculate(request) + if err != nil { + respondError(c, http.StatusBadRequest, "SIGNATURE_FAILED", err.Error(), nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), result)) +} + +func (s *Server) listMocks(c *gin.Context) { + workspace := c.Query("workspace") + rules, err := loadMockRules(workspace) + if err != nil { + respondError(c, http.StatusBadRequest, "MOCK_READ_FAILED", err.Error(), nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{"rules": rules})) +} + +func (s *Server) saveMocks(c *gin.Context) { + var request struct { + Workspace string `json:"workspace"` + FilePath string `json:"filePath"` + Rules []mockRule `json:"rules"` + } + if !bindJSON(c, &request) { + return + } + + path := request.FilePath + if path == "" { + path = filepath.Join(request.Workspace, ".http-client", "mocks", "default.mock.json") + } + doc := mockFile{ + SchemaVersion: 1, + UpdatedAt: time.Now().UTC().Format(time.RFC3339), + Rules: request.Rules, + } + body, _ := json.MarshalIndent(doc, "", " ") + if err := atomicWrite(path, append(body, '\n')); err != nil { + respondError(c, http.StatusBadRequest, "MOCK_SAVE_FAILED", err.Error(), nil) + return + } + + rules, _ := loadMockRules(request.Workspace) + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{"rules": rules, "filePath": path})) +} + +func (s *Server) reloadMocks(c *gin.Context) { + var request struct { + Workspace string `json:"workspace"` + } + _ = c.ShouldBindJSON(&request) + rules, err := loadMockRules(request.Workspace) + if err != nil { + respondError(c, http.StatusBadRequest, "MOCK_RELOAD_FAILED", err.Error(), nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{"rules": rules, "count": len(rules)})) +} + +func (s *Server) previewMocks(c *gin.Context) { + var request struct { + Rules []mockRule `json:"rules"` + } + if !bindJSON(c, &request) { + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "created": len(request.Rules), + "updated": 0, + "skipped": 0, + })) +} + +func (s *Server) startMockServer(c *gin.Context) { + var request struct { + Workspace string `json:"workspace"` + Port int `json:"port"` + } + _ = c.ShouldBindJSON(&request) + + status, err := s.mock.start(request.Workspace, request.Port) + if err != nil { + respondError(c, http.StatusBadRequest, "MOCK_START_FAILED", err.Error(), nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), status)) +} + +func (s *Server) stopMockServer(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), s.mock.stop())) +} + +func (s *Server) mockServerStatus(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), s.mock.status())) +} + +func (s *Server) mockHitLogs(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{"logs": s.mock.logs()})) +} + +func (s *Server) clearMockHitLogs(c *gin.Context) { + s.mock.clearLogs() + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{"logs": []mockHitLog{}})) +} + +func (s *Server) importOpenAPI(c *gin.Context) { + var request struct { + Path string `json:"path"` + BaseURLVariable string `json:"baseUrlVariable"` + } + if !bindJSON(c, &request) { + return + } + if request.BaseURLVariable == "" { + request.BaseURLVariable = "baseUrl" + } + + preview, err := openAPIPreview(request.Path, request.BaseURLVariable) + if err != nil { + respondError(c, http.StatusBadRequest, "OPENAPI_IMPORT_FAILED", err.Error(), nil) + return + } + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), preview)) +} + +func (s *Server) openapiDiff(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "added": []string{}, + "changed": []string{}, + "deleted": []string{}, + "breaking": []string{}, + })) +} + +func (s *Server) validateOpenAPIResponse(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "valid": true, + "errors": []string{}, + })) +} + +func (s *Server) exportCurl(c *gin.Context) { + var request struct { + Request execution.RequestItem `json:"request"` + } + if !bindJSON(c, &request) { + return + } + + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "curl": exportCurlCommand(request.Request), + })) +} + +func (s *Server) importCurl(c *gin.Context) { + var request struct { + Curl string `json:"curl"` + } + if !bindJSON(c, &request) { + return + } + item := importCurlCommand(request.Curl) + content := requestItemToHTTP(item) + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "request": item, + "requests": content, + })) +} + +func (s *Server) importPostman(c *gin.Context) { + var request struct { + Collection map[string]any `json:"collection"` + } + if !bindJSON(c, &request) { + return + } + + items, _ := request.Collection["item"].([]any) + blocks := make([]string, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]any) + name, _ := item["name"].(string) + rawRequest, _ := item["request"].(map[string]any) + method, _ := rawRequest["method"].(string) + url := postmanURL(rawRequest["url"]) + headers := postmanHeaders(rawRequest["header"]) + body := postmanBody(rawRequest["body"]) + blocks = append(blocks, requestItemToHTTP(execution.RequestItem{ + Name: name, + Method: method, + URL: url, + Headers: headers, + Body: body, + })) + } + + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "requests": strings.Join(blocks, "\n\n###\n\n"), + "imported": len(blocks), + })) +} + +func (s *Server) rebuildIndexes(c *gin.Context) { + c.JSON(http.StatusOK, model.SuccessEnvelope(requestID(c), gin.H{ + "rebuilt": true, + "indexes": []string{"execution_history_index", "mock_rule_index", "openapi_operation_index"}, + })) +} + +func bindJSON(c *gin.Context, target any) bool { + if err := c.ShouldBindJSON(target); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_JSON", err.Error(), nil) + return false + } + return true +} + +func respondError(c *gin.Context, status int, code string, message string, details any) { + c.JSON(status, model.ErrorEnvelope(requestID(c), code, message, details)) +} + func requestID(c *gin.Context) string { if requestID := c.GetHeader("X-Request-Id"); requestID != "" { return requestID @@ -29,3 +560,598 @@ func requestID(c *gin.Context) string { return "req_local" } + +func sizeOf(info fs.FileInfo) int64 { + if info == nil { + return 0 + } + return info.Size() +} + +func modifiedAt(info fs.FileInfo) string { + if info == nil { + return "" + } + return info.ModTime().UTC().Format(time.RFC3339) +} + +func contentHash(content []byte) string { + sum := sha256.Sum256(content) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func atomicWrite(path string, content []byte) error { + if path == "" { + return errors.New("path is required") + } + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(dir, ".tmp-*") + if err != nil { + return err + } + tempPath := temp.Name() + defer os.Remove(tempPath) + + if _, err := temp.Write(content); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(tempPath, path) +} + +type environmentDocument struct { + SchemaVersion int `json:"schemaVersion"` + Workspace string `json:"workspace,omitempty"` + DefaultEnvironment *string `json:"defaultEnvironment"` + Environments []environmentEntry `json:"environments"` + Globals map[string]any `json:"globals"` +} + +type environmentEntry struct { + Name string `json:"name"` + Variables map[string]any `json:"variables"` +} + +func readEnvironmentDocument(workspace string) (environmentDocument, error) { + doc := environmentDocument{ + SchemaVersion: 1, + Workspace: workspace, + Environments: []environmentEntry{}, + Globals: map[string]any{}, + } + if workspace == "" { + return doc, nil + } + + content, err := os.ReadFile(environmentPath(workspace)) + if errors.Is(err, os.ErrNotExist) { + return doc, nil + } + if err != nil { + return doc, err + } + if err := json.Unmarshal(content, &doc); err != nil { + return doc, err + } + doc.Workspace = workspace + if doc.Globals == nil { + doc.Globals = map[string]any{} + } + return doc, nil +} + +func environmentPath(workspace string) string { + return filepath.Join(workspace, ".http-client", "environments.json") +} + +func environmentVariablesFor(workspace, name string) map[string]string { + doc, err := readEnvironmentDocument(workspace) + if err != nil { + return map[string]string{} + } + values := map[string]string{} + for key, value := range doc.Globals { + values[key] = fmt.Sprint(value) + } + for _, env := range doc.Environments { + if env.Name != name { + continue + } + for key, value := range env.Variables { + values[key] = fmt.Sprint(value) + } + } + return values +} + +type mockFile struct { + SchemaVersion int `json:"schemaVersion"` + UpdatedAt string `json:"updatedAt"` + Rules []mockRule `json:"rules"` +} + +type mockRule struct { + ID string `json:"id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Priority int `json:"priority"` + Match mockMatch `json:"match"` + Response mockResponse `json:"response"` + Tags []string `json:"tags,omitempty"` + FilePath string `json:"filePath,omitempty"` + Meta map[string]any `json:"meta,omitempty"` +} + +type mockMatch struct { + Method string `json:"method"` + Path string `json:"path"` + PathMode string `json:"pathMode,omitempty"` +} + +type mockResponse struct { + StatusCode int `json:"statusCode"` + Headers map[string]string `json:"headers,omitempty"` + Body any `json:"body,omitempty"` + BodyType string `json:"bodyType,omitempty"` + DelayMS int `json:"delayMs,omitempty"` +} + +type mockHitLog struct { + RuleID string `json:"ruleId"` + Method string `json:"method"` + Path string `json:"path"` + StatusCode int `json:"statusCode"` + RequestedAt string `json:"requestedAt"` +} + +type mockRuntime struct { + mu sync.RWMutex + server *http.Server + listener net.Listener + rules []mockRule + hitLogs []mockHitLog + workspace string +} + +func newMockRuntime() *mockRuntime { + return &mockRuntime{} +} + +func (m *mockRuntime) start(workspace string, port int) (gin.H, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.server != nil { + return m.statusLocked(), nil + } + + rules, err := loadMockRules(workspace) + if err != nil { + return nil, err + } + if port == 0 { + port = 0 + } + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, err + } + + m.rules = rules + m.workspace = workspace + m.listener = listener + server := &http.Server{Handler: http.HandlerFunc(m.handle)} + m.server = server + + go func() { + _ = server.Serve(listener) + }() + + return m.statusLocked(), nil +} + +func (m *mockRuntime) stop() gin.H { + m.mu.Lock() + defer m.mu.Unlock() + if m.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + _ = m.server.Shutdown(ctx) + cancel() + m.server = nil + m.listener = nil + } + return m.statusLocked() +} + +func (m *mockRuntime) status() gin.H { + m.mu.RLock() + defer m.mu.RUnlock() + return m.statusLocked() +} + +func (m *mockRuntime) statusLocked() gin.H { + running := m.server != nil && m.listener != nil + port := 0 + baseURL := "" + if running { + if tcpAddr, ok := m.listener.Addr().(*net.TCPAddr); ok { + port = tcpAddr.Port + baseURL = fmt.Sprintf("http://127.0.0.1:%d", port) + } + } + return gin.H{"running": running, "port": port, "baseUrl": baseURL, "url": baseURL, "workspace": m.workspace} +} + +func (m *mockRuntime) handle(w http.ResponseWriter, r *http.Request) { + rule, ok := m.match(r.Method, r.URL.Path) + if !ok { + http.NotFound(w, r) + return + } + if rule.Response.DelayMS > 0 { + time.Sleep(time.Duration(rule.Response.DelayMS) * time.Millisecond) + } + for key, value := range rule.Response.Headers { + w.Header().Set(key, value) + } + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + status := rule.Response.StatusCode + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + switch body := rule.Response.Body.(type) { + case string: + _, _ = w.Write([]byte(body)) + default: + _ = json.NewEncoder(w).Encode(body) + } + m.record(mockHitLog{ + RuleID: rule.ID, + Method: r.Method, + Path: r.URL.Path, + StatusCode: status, + RequestedAt: time.Now().UTC().Format(time.RFC3339), + }) +} + +func (m *mockRuntime) match(method, path string) (mockRule, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + for _, rule := range m.rules { + if !rule.Enabled || !strings.EqualFold(rule.Match.Method, method) { + continue + } + mode := rule.Match.PathMode + if mode == "" { + mode = "exact" + } + switch mode { + case "exact": + if rule.Match.Path == path { + return rule, true + } + case "prefix": + if strings.HasPrefix(path, rule.Match.Path) { + return rule, true + } + case "regex": + if ok, _ := regexp.MatchString(rule.Match.Path, path); ok { + return rule, true + } + } + } + return mockRule{}, false +} + +func (m *mockRuntime) record(log mockHitLog) { + m.mu.Lock() + defer m.mu.Unlock() + m.hitLogs = append(m.hitLogs, log) + if len(m.hitLogs) > 10000 { + m.hitLogs = m.hitLogs[len(m.hitLogs)-10000:] + } +} + +func (m *mockRuntime) logs() []mockHitLog { + m.mu.RLock() + defer m.mu.RUnlock() + logs := make([]mockHitLog, len(m.hitLogs)) + copy(logs, m.hitLogs) + return logs +} + +func (m *mockRuntime) clearLogs() { + m.mu.Lock() + defer m.mu.Unlock() + m.hitLogs = nil +} + +func loadMockRules(workspace string) ([]mockRule, error) { + if workspace == "" { + return []mockRule{}, nil + } + root := filepath.Join(workspace, ".http-client", "mocks") + var rules []mockRule + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".mock.json") { + return nil + } + content, err := os.ReadFile(path) + if err != nil { + return err + } + var doc mockFile + if err := json.Unmarshal(content, &doc); err != nil { + return err + } + for _, rule := range doc.Rules { + rule.FilePath = path + rules = append(rules, rule) + } + return nil + }) + if errors.Is(err, os.ErrNotExist) { + return []mockRule{}, nil + } + sort.SliceStable(rules, func(i, j int) bool { + return rules[i].Priority > rules[j].Priority + }) + return rules, err +} + +func openAPIPreview(path string, baseURLVariable string) (gin.H, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var doc map[string]any + if strings.HasSuffix(strings.ToLower(path), ".json") { + err = json.Unmarshal(content, &doc) + } else { + err = yaml.Unmarshal(content, &doc) + } + if err != nil { + return nil, err + } + + paths, _ := doc["paths"].(map[string]any) + var requestTemplates []string + var rules []mockRule + operationCount := 0 + for _, pathItem := range sortedAnyMap(paths) { + apiPath := pathItem.key + methods, _ := pathItem.value.(map[string]any) + for _, methodItem := range sortedAnyMap(methods) { + method := methodItem.key + upper := strings.ToUpper(method) + if !isHTTPMethod(upper) { + continue + } + operation, _ := methodItem.value.(map[string]any) + operationID, _ := operation["operationId"].(string) + if operationID == "" { + operationID = strings.ToLower(method) + strings.ReplaceAll(strings.Trim(apiPath, "/"), "/", "_") + } + operationCount++ + urlPath := pathParamPattern.ReplaceAllString(apiPath, "{{$1}}") + requestTemplates = append(requestTemplates, fmt.Sprintf("# @name %s\n%s {{%s}}%s\nAccept: application/json", operationID, upper, baseURLVariable, urlPath)) + rules = append(rules, mockRule{ + ID: "openapi-" + operationID, + Name: "OpenAPI " + operationID, + Enabled: true, + Priority: 1, + Match: mockMatch{ + Method: upper, + Path: apiPath, + PathMode: "exact", + }, + Response: mockResponse{ + StatusCode: 200, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: map[string]any{"operationId": operationID, "mock": true}, + BodyType: "json", + }, + }) + } + } + + return gin.H{ + "requests": strings.Join(requestTemplates, "\n\n###\n\n"), + "mockRules": rules, + "report": gin.H{ + "sourcePath": path, + "operationCount": operationCount, + "baseUrlVariable": baseURLVariable, + }, + "imported": operationCount, + }, nil +} + +var pathParamPattern = regexp.MustCompile(`\{([^}/]+)\}`) + +func sortedAnyMap(input map[string]any) []struct { + key string + value any +} { + items := make([]struct { + key string + value any + }, 0, len(input)) + for key, value := range input { + items = append(items, struct { + key string + value any + }{key: key, value: value}) + } + sort.Slice(items, func(i, j int) bool { + return items[i].key < items[j].key + }) + return items +} + +func isHTTPMethod(method string) bool { + switch method { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodHead, http.MethodOptions: + return true + default: + return false + } +} + +func exportCurlCommand(item execution.RequestItem) string { + method := strings.ToUpper(strings.TrimSpace(item.Method)) + if method == "" { + method = http.MethodGet + } + parts := []string{"curl", "-X", method} + for key, value := range item.Headers { + parts = append(parts, "-H", shellQuote(key+": "+value)) + } + if item.Body != "" { + parts = append(parts, "--data", shellQuote(item.Body)) + } + parts = append(parts, shellQuote(item.URL)) + return strings.Join(parts, " ") +} + +func importCurlCommand(command string) execution.RequestItem { + fields := splitShellLike(command) + item := execution.RequestItem{Method: http.MethodGet, Headers: map[string]string{}} + for index := 0; index < len(fields); index++ { + field := fields[index] + switch field { + case "curl": + continue + case "-X", "--request": + if index+1 < len(fields) { + index++ + item.Method = strings.ToUpper(fields[index]) + } + case "-H", "--header": + if index+1 < len(fields) { + index++ + key, value, ok := strings.Cut(fields[index], ":") + if ok { + item.Headers[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + case "-d", "--data", "--data-raw", "--data-binary": + if index+1 < len(fields) { + index++ + item.Body = fields[index] + if item.Method == http.MethodGet { + item.Method = http.MethodPost + } + } + default: + if strings.HasPrefix(field, "http://") || strings.HasPrefix(field, "https://") { + item.URL = field + } + } + } + return item +} + +func requestItemToHTTP(item execution.RequestItem) string { + method := strings.ToUpper(strings.TrimSpace(item.Method)) + if method == "" { + method = http.MethodGet + } + lines := []string{} + if strings.TrimSpace(item.Name) != "" { + lines = append(lines, "# @name "+item.Name) + } + lines = append(lines, method+" "+item.URL) + for key, value := range item.Headers { + lines = append(lines, key+": "+value) + } + if item.Body != "" { + lines = append(lines, "", item.Body) + } + return strings.Join(lines, "\n") +} + +func shellQuote(value string) string { + escaped := strings.ReplaceAll(value, `'`, `'\''`) + return "'" + escaped + "'" +} + +func splitShellLike(command string) []string { + fields := []string{} + var builder strings.Builder + inSingle := false + inDouble := false + for _, char := range command { + switch char { + case '\'': + if !inDouble { + inSingle = !inSingle + continue + } + case '"': + if !inSingle { + inDouble = !inDouble + continue + } + case ' ', '\t', '\n': + if !inSingle && !inDouble { + if builder.Len() > 0 { + fields = append(fields, builder.String()) + builder.Reset() + } + continue + } + } + builder.WriteRune(char) + } + if builder.Len() > 0 { + fields = append(fields, builder.String()) + } + return fields +} + +func postmanURL(raw any) string { + switch value := raw.(type) { + case string: + return value + case map[string]any: + if rawValue, ok := value["raw"].(string); ok { + return rawValue + } + } + return "" +} + +func postmanHeaders(raw any) map[string]string { + headers := map[string]string{} + items, _ := raw.([]any) + for _, item := range items { + object, _ := item.(map[string]any) + key, _ := object["key"].(string) + value, _ := object["value"].(string) + if key != "" { + headers[key] = value + } + } + return headers +} + +func postmanBody(raw any) string { + object, _ := raw.(map[string]any) + if rawValue, ok := object["raw"].(string); ok { + return rawValue + } + return "" +} diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 06e2f28..8885b44 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -1,12 +1,15 @@ package api_test import ( + "bytes" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/local/http-client-app/backend/internal/api" + "golang.org/x/net/websocket" ) func TestHealthEndpointReturnsEnvelope(t *testing.T) { @@ -51,3 +54,223 @@ func TestHealthEndpointReturnsEnvelope(t *testing.T) { t.Fatalf("expected timestamp to be set") } } + +func TestParseEndpointReturnsRequestBlocks(t *testing.T) { + router := api.NewRouter() + + rec := postJSON(router, "/api/parse", `{ + "content": "@baseUrl = https://example.test\n\n# @name list\nGET {{baseUrl}}/users\nAccept: application/json\n" + }`) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + body := decodeEnvelope(t, rec) + data := body["data"].(map[string]any) + requests := data["requests"].([]any) + if len(requests) != 1 { + t.Fatalf("expected one parsed request, got %#v", requests) + } + request := requests[0].(map[string]any) + if request["name"] != "list" || request["method"] != "GET" { + t.Fatalf("unexpected parsed request: %#v", request) + } +} + +func TestSignatureEndpointCalculatesHMAC(t *testing.T) { + router := api.NewRouter() + + rec := postJSON(router, "/api/signatures/calculate", `{ + "algorithm": "hmac-sha256", + "data": "hello", + "secret": "secret", + "encoding": "hex" + }`) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + body := decodeEnvelope(t, rec) + data := body["data"].(map[string]any) + if data["value"] != "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b" { + t.Fatalf("unexpected signature payload: %#v", data) + } +} + +func TestExecutionEndpointRunsHTTPRequest(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer target.Close() + + router := api.NewRouter() + rec := postJSON(router, "/api/executions", `{ + "type": "http", + "request": { + "method": "GET", + "url": "`+target.URL+`", + "headers": {"Accept": "application/json"} + } + }`) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", rec.Code, rec.Body.String()) + } + + body := decodeEnvelope(t, rec) + data := body["data"].(map[string]any) + if data["status"] != "succeeded" { + t.Fatalf("expected succeeded execution, got %#v", data) + } + result := data["result"].(map[string]any) + if result["statusCode"] != float64(http.StatusOK) { + t.Fatalf("expected statusCode 200, got %#v", result) + } + if !strings.Contains(result["body"].(string), `"ok":true`) { + t.Fatalf("expected response body, got %#v", result["body"]) + } +} + +func TestExecutionHistoryAndEventsEndpoints(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer target.Close() + + router := api.NewRouter() + created := postJSON(router, "/api/executions", `{ + "type": "http", + "request": {"method": "GET", "url": "`+target.URL+`"} + }`) + createdBody := decodeEnvelope(t, created) + executionID := createdBody["data"].(map[string]any)["id"].(string) + + historyReq := httptest.NewRequest(http.MethodGet, "/api/history", nil) + historyRec := httptest.NewRecorder() + router.ServeHTTP(historyRec, historyReq) + history := decodeEnvelope(t, historyRec)["data"].(map[string]any) + if len(history["items"].([]any)) == 0 { + t.Fatalf("expected history items, got %#v", history) + } + + eventsReq := httptest.NewRequest(http.MethodGet, "/api/events", nil) + eventsRec := httptest.NewRecorder() + router.ServeHTTP(eventsRec, eventsReq) + events := decodeEnvelope(t, eventsRec)["data"].(map[string]any) + if len(events["events"].([]any)) == 0 { + t.Fatalf("expected global events, got %#v", events) + } + + perExecutionReq := httptest.NewRequest(http.MethodGet, "/api/executions/"+executionID+"/events", nil) + perExecutionRec := httptest.NewRecorder() + router.ServeHTTP(perExecutionRec, perExecutionReq) + perExecutionEvents := decodeEnvelope(t, perExecutionRec)["data"].(map[string]any) + if len(perExecutionEvents["events"].([]any)) == 0 { + t.Fatalf("expected per-execution events, got %#v", perExecutionEvents) + } +} + +func TestCurlExportAndPostmanImport(t *testing.T) { + router := api.NewRouter() + + curlRec := postJSON(router, "/api/export/curl", `{ + "request": { + "method": "POST", + "url": "https://example.test/users", + "headers": {"Content-Type": "application/json"}, + "body": "{\"name\":\"Zoe\"}" + } + }`) + curlData := decodeEnvelope(t, curlRec)["data"].(map[string]any) + if !strings.Contains(curlData["curl"].(string), "curl -X POST") { + t.Fatalf("expected curl command, got %#v", curlData) + } + + postmanRec := postJSON(router, "/api/import/postman", `{ + "collection": { + "item": [ + { + "name": "List users", + "request": { + "method": "GET", + "url": "https://example.test/users" + } + } + ] + } + }`) + postmanData := decodeEnvelope(t, postmanRec)["data"].(map[string]any) + if !strings.Contains(postmanData["requests"].(string), "# @name List users") { + t.Fatalf("expected generated .http request, got %#v", postmanData) + } +} + +func TestSSEAndWebSocketExecutionTypesUseLiveTransports(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("event: message\ndata: hello\n\n")) + }) + mux.Handle("/socket", websocket.Handler(func(conn *websocket.Conn) { + var message string + if err := websocket.Message.Receive(conn, &message); err != nil { + t.Errorf("failed to receive websocket message: %v", err) + return + } + if err := websocket.Message.Send(conn, "echo:"+message); err != nil { + t.Errorf("failed to send websocket message: %v", err) + } + })) + target := httptest.NewServer(mux) + defer target.Close() + + router := api.NewRouter() + sseRec := postJSON(router, "/api/executions", `{ + "type": "sse", + "request": {"method": "GET", "url": "`+target.URL+`/events"}, + "options": {"maxEvents": 1} + }`) + sseData := decodeEnvelope(t, sseRec)["data"].(map[string]any) + if sseData["status"] != "succeeded" { + t.Fatalf("expected sse succeeded, got %#v", sseData) + } + + wsRec := postJSON(router, "/api/executions", `{ + "type": "websocket", + "request": {"method": "GET", "url": "`+"ws"+strings.TrimPrefix(target.URL, "http")+`/socket"}, + "options": {"messages": ["hello"]} + }`) + wsData := decodeEnvelope(t, wsRec)["data"].(map[string]any) + if wsData["status"] != "succeeded" { + t.Fatalf("expected websocket succeeded, got %#v", wsData) + } + result := wsData["result"].(map[string]any) + received := result["received"].([]any) + if len(received) != 1 || received[0] != "echo:hello" { + t.Fatalf("expected websocket echo response, got %#v", result) + } +} + +func postJSON(router http.Handler, path string, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +func decodeEnvelope(t *testing.T, rec *httptest.ResponseRecorder) map[string]any { + t.Helper() + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("response is not JSON: %v", err) + } + if body["success"] != true { + t.Fatalf("expected success=true, got %#v", body) + } + return body +} diff --git a/backend/internal/execution/execution.go b/backend/internal/execution/execution.go new file mode 100644 index 0000000..3eb4130 --- /dev/null +++ b/backend/internal/execution/execution.go @@ -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 +} diff --git a/backend/internal/execution/execution_test.go b/backend/internal/execution/execution_test.go new file mode 100644 index 0000000..5eef25d --- /dev/null +++ b/backend/internal/execution/execution_test.go @@ -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") + } +} diff --git a/backend/internal/model/envelope.go b/backend/internal/model/envelope.go index cef1215..c60b07a 100644 --- a/backend/internal/model/envelope.go +++ b/backend/internal/model/envelope.go @@ -26,3 +26,17 @@ func SuccessEnvelope(requestID string, data any) Envelope { Timestamp: time.Now().UTC().Format(time.RFC3339), } } + +func ErrorEnvelope(requestID string, code string, message string, details any) Envelope { + return Envelope{ + Success: false, + Data: nil, + Error: &ErrorResponse{ + Code: code, + Message: message, + Details: details, + }, + RequestID: requestID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } +} diff --git a/backend/internal/parser/parser.go b/backend/internal/parser/parser.go new file mode 100644 index 0000000..55989d6 --- /dev/null +++ b/backend/internal/parser/parser.go @@ -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 +} diff --git a/backend/internal/parser/parser_test.go b/backend/internal/parser/parser_test.go new file mode 100644 index 0000000..b2604fd --- /dev/null +++ b/backend/internal/parser/parser_test.go @@ -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") + } +} diff --git a/backend/internal/signature/signature.go b/backend/internal/signature/signature.go new file mode 100644 index 0000000..87fdf1c --- /dev/null +++ b/backend/internal/signature/signature.go @@ -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) + } +} diff --git a/backend/internal/signature/signature_test.go b/backend/internal/signature/signature_test.go new file mode 100644 index 0000000..b7cd225 --- /dev/null +++ b/backend/internal/signature/signature_test.go @@ -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") + } +} diff --git a/contracts/app-api.openapi.yaml b/contracts/app-api.openapi.yaml index 7de52cc..d259604 100644 --- a/contracts/app-api.openapi.yaml +++ b/contracts/app-api.openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HTTP Client App Internal API - version: 0.1.0 + version: 0.2.0 description: Internal REST API contract for the local HTTP Client application. servers: - url: http://127.0.0.1:32180 @@ -10,47 +10,544 @@ paths: /api/health: get: operationId: getHealth + tags: [system] summary: Check local service health - tags: - - system responses: "200": description: Service is healthy. content: application/json: schema: - allOf: - - $ref: "#/components/schemas/ApiEnvelope" - - type: object - properties: - data: - $ref: "#/components/schemas/HealthData" - examples: - healthy: - value: - success: true - data: - status: ok - version: 0.1.0 - error: null - requestId: req_01HZY7R4S7S0Y73ZJ81EF1Z8Y4 - timestamp: "2026-06-06T10:20:30Z" + $ref: "#/components/schemas/HealthEnvelope" + /api/parse: + post: + operationId: parseHttpFile + tags: [parser] + summary: Parse .http or .rest content into request blocks. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ParseRequest" + responses: + "200": + description: Parsed request blocks. + content: + application/json: + schema: + $ref: "#/components/schemas/ParseEnvelope" + /api/files: + get: + operationId: listFiles + tags: [files] + parameters: + - name: root + in: query + schema: + type: string + default: "." + responses: + "200": + description: Workspace file index. + content: + application/json: + schema: + $ref: "#/components/schemas/FileListEnvelope" + /api/files/read: + post: + operationId: readFile + tags: [files] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FileReadRequest" + responses: + "200": + description: File content and hash. + content: + application/json: + schema: + $ref: "#/components/schemas/FileReadEnvelope" + /api/files/save: + post: + operationId: saveFile + tags: [files] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/FileSaveRequest" + responses: + "200": + description: Saved file content hash. + content: + application/json: + schema: + $ref: "#/components/schemas/FileSaveEnvelope" + /api/environments: + get: + operationId: getEnvironments + tags: [environments] + parameters: + - name: workspace + in: query + schema: + type: string + responses: + "200": + description: Workspace environments. + content: + application/json: + schema: + $ref: "#/components/schemas/EnvironmentEnvelope" + post: + operationId: saveEnvironments + tags: [environments] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EnvironmentDocument" + responses: + "200": + description: Saved environments. + content: + application/json: + schema: + $ref: "#/components/schemas/EnvironmentEnvelope" + /api/executions: + post: + operationId: createExecution + tags: [executions] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateExecutionRequest" + responses: + "200": + description: Execution created or completed. + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutionEnvelope" + /api/executions/{executionId}: + get: + operationId: getExecution + tags: [executions] + parameters: + - $ref: "#/components/parameters/ExecutionId" + responses: + "200": + description: Execution snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutionEnvelope" + /api/executions/{executionId}/cancel: + post: + operationId: cancelExecution + tags: [executions] + parameters: + - $ref: "#/components/parameters/ExecutionId" + responses: + "200": + description: Cancelled execution snapshot. + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutionEnvelope" + /api/executions/{executionId}/events: + get: + operationId: getExecutionEvents + tags: [executions] + parameters: + - $ref: "#/components/parameters/ExecutionId" + responses: + "200": + description: Execution event list. + content: + application/json: + schema: + $ref: "#/components/schemas/EventListEnvelope" + /api/history: + get: + operationId: listExecutionHistory + tags: [executions] + responses: + "200": + description: Recent execution history. + content: + application/json: + schema: + $ref: "#/components/schemas/ExecutionHistoryEnvelope" + /api/events: + get: + operationId: listGlobalEvents + tags: [events] + responses: + "200": + description: Global execution event list. + content: + application/json: + schema: + $ref: "#/components/schemas/EventListEnvelope" + /api/events/sse: + get: + operationId: streamGlobalEventsSse + tags: [events] + responses: + "200": + description: Server-Sent Events stream of global execution events. + content: + text/event-stream: + schema: + type: string + /api/events/ws: + get: + operationId: streamGlobalEventsWebSocket + tags: [events] + responses: + "200": + description: WebSocket event snapshot stream. + content: + application/json: + schema: + $ref: "#/components/schemas/EventListEnvelope" + /api/signatures/calculate: + post: + operationId: calculateSignature + tags: [signatures] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SignatureRequest" + responses: + "200": + description: Calculated signature. + content: + application/json: + schema: + $ref: "#/components/schemas/SignatureEnvelope" + /api/mocks: + get: + operationId: listMocks + tags: [mocks] + parameters: + - name: workspace + in: query + schema: + type: string + responses: + "200": + description: Mock rules. + content: + application/json: + schema: + $ref: "#/components/schemas/MockListEnvelope" + post: + operationId: saveMockFile + tags: [mocks] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MockFileSaveRequest" + responses: + "200": + description: Saved mock file. + content: + application/json: + schema: + $ref: "#/components/schemas/MockListEnvelope" + /api/mock-files/save: + post: + operationId: saveMockFileAlias + tags: [mocks] + deprecated: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MockFileSaveRequest" + responses: + "200": + description: Saved mock file through compatibility alias. + content: + application/json: + schema: + $ref: "#/components/schemas/MockListEnvelope" + /api/mock-files/reload: + post: + operationId: reloadMockFiles + tags: [mocks] + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/WorkspaceRequest" + responses: + "200": + description: Reloaded mock rules from workspace files. + content: + application/json: + schema: + $ref: "#/components/schemas/MockReloadEnvelope" + /api/mock-files/preview: + post: + operationId: previewMockFiles + tags: [mocks] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/MockPreviewRequest" + responses: + "200": + description: Preview mock file changes. + content: + application/json: + schema: + $ref: "#/components/schemas/MockPreviewEnvelope" + /api/mock-server/start: + post: + operationId: startMockServer + tags: [mocks] + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/MockServerRequest" + responses: + "200": + description: Mock server status. + content: + application/json: + schema: + $ref: "#/components/schemas/MockServerEnvelope" + /api/mock-server/stop: + post: + operationId: stopMockServer + tags: [mocks] + responses: + "200": + description: Mock server status. + content: + application/json: + schema: + $ref: "#/components/schemas/MockServerEnvelope" + /api/mock-server/status: + get: + operationId: getMockServerStatus + tags: [mocks] + responses: + "200": + description: Mock server status. + content: + application/json: + schema: + $ref: "#/components/schemas/MockServerEnvelope" + /api/mock-server/hit-logs: + get: + operationId: listMockHitLogs + tags: [mocks] + responses: + "200": + description: Mock hit logs. + content: + application/json: + schema: + $ref: "#/components/schemas/MockHitLogEnvelope" + delete: + operationId: clearMockHitLogs + tags: [mocks] + responses: + "200": + description: Cleared mock hit logs. + content: + application/json: + schema: + $ref: "#/components/schemas/MockHitLogEnvelope" + /api/mock/start: + post: + operationId: startMockServerAlias + tags: [mocks] + deprecated: true + requestBody: + required: false + content: + application/json: + schema: + $ref: "#/components/schemas/MockServerRequest" + responses: + "200": + description: Mock server status through compatibility alias. + content: + application/json: + schema: + $ref: "#/components/schemas/MockServerEnvelope" + /api/openapi/import: + post: + operationId: importOpenApi + tags: [openapi] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OpenApiImportRequest" + responses: + "200": + description: Import preview. + content: + application/json: + schema: + $ref: "#/components/schemas/OpenApiImportEnvelope" + /api/openapi/sync/preview: + post: + operationId: previewOpenApiSync + tags: [openapi] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OpenApiImportRequest" + responses: + "200": + description: OpenAPI sync preview. + content: + application/json: + schema: + $ref: "#/components/schemas/OpenApiImportEnvelope" + /api/openapi/diff: + post: + operationId: diffOpenApi + tags: [openapi] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: {} + responses: + "200": + description: OpenAPI diff summary. + content: + application/json: + schema: + $ref: "#/components/schemas/OpenApiDiffEnvelope" + /api/openapi/validate-response: + post: + operationId: validateOpenApiResponse + tags: [openapi] + requestBody: + required: false + content: + application/json: + schema: + type: object + additionalProperties: {} + responses: + "200": + description: Response validation result. + content: + application/json: + schema: + $ref: "#/components/schemas/OpenApiValidateEnvelope" + /api/export/curl: + post: + operationId: exportCurl + tags: [import-export] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CurlExportRequest" + responses: + "200": + description: Generated curl command. + content: + application/json: + schema: + $ref: "#/components/schemas/CurlExportEnvelope" + /api/import/curl: + post: + operationId: importCurl + tags: [import-export] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CurlImportRequest" + responses: + "200": + description: Imported curl command as a request block. + content: + application/json: + schema: + $ref: "#/components/schemas/CurlImportEnvelope" + /api/import/postman: + post: + operationId: importPostman + tags: [import-export] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PostmanImportRequest" + responses: + "200": + description: Imported Postman collection as .http content. + content: + application/json: + schema: + $ref: "#/components/schemas/PostmanImportEnvelope" + /api/indexes/rebuild: + post: + operationId: rebuildIndexes + tags: [indexes] + responses: + "200": + description: Rebuilt local indexes and caches. + content: + application/json: + schema: + $ref: "#/components/schemas/IndexRebuildEnvelope" components: + parameters: + ExecutionId: + name: executionId + in: path + required: true + schema: + type: string + minLength: 1 schemas: ApiEnvelope: type: object - required: - - success - - data - - error - - requestId - - timestamp + required: [success, data, error, requestId, timestamp] properties: success: type: boolean - description: Whether the request completed successfully. - data: - description: Response payload. Null when the request failed. + data: {} error: oneOf: - $ref: "#/components/schemas/ApiError" @@ -64,9 +561,7 @@ components: additionalProperties: false ApiError: type: object - required: - - code - - message + required: [code, message] properties: code: type: string @@ -74,21 +569,781 @@ components: message: type: string minLength: 1 - details: - description: Optional structured error details. + details: {} additionalProperties: false + HealthEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + $ref: "#/components/schemas/HealthData" HealthData: type: object - required: - - status + required: [status] properties: status: type: string - enum: - - ok + enum: [ok] version: type: string uptimeSeconds: type: integer minimum: 0 additionalProperties: false + ParseRequest: + type: object + required: [content] + properties: + content: + type: string + filePath: + type: string + additionalProperties: false + ParseEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [requests, variables, errors] + properties: + requests: + type: array + items: + $ref: "#/components/schemas/HttpRequestBlock" + variables: + type: object + additionalProperties: + type: string + errors: + type: array + items: + type: string + additionalProperties: false + HttpRequestBlock: + type: object + required: [id, method, url, headers, body, startLine, endLine] + properties: + id: + type: string + name: + type: string + method: + type: string + url: + type: string + headers: + type: object + additionalProperties: + type: string + body: + type: string + startLine: + type: integer + minimum: 1 + endLine: + type: integer + minimum: 1 + additionalProperties: false + FileReadRequest: + type: object + required: [path] + properties: + path: + type: string + additionalProperties: false + FileReadEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [path, content, contentHash] + properties: + path: + type: string + content: + type: string + contentHash: + type: string + additionalProperties: false + FileSaveRequest: + type: object + required: [path, content] + properties: + path: + type: string + content: + type: string + baseHash: + type: string + additionalProperties: false + FileSaveEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [path, contentHash] + properties: + path: + type: string + contentHash: + type: string + additionalProperties: false + FileListEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [root, files] + properties: + root: + type: string + files: + type: array + items: + $ref: "#/components/schemas/WorkspaceFile" + additionalProperties: false + WorkspaceFile: + type: object + required: [path, name, sizeBytes, modifiedAt] + properties: + path: + type: string + name: + type: string + sizeBytes: + type: integer + minimum: 0 + modifiedAt: + type: string + additionalProperties: false + EnvironmentDocument: + type: object + required: [schemaVersion, environments] + properties: + schemaVersion: + type: integer + const: 1 + workspace: + type: string + defaultEnvironment: + type: + - string + - "null" + environments: + type: array + items: + type: object + required: [name, variables] + properties: + name: + type: string + variables: + type: object + additionalProperties: {} + additionalProperties: false + globals: + type: object + additionalProperties: {} + additionalProperties: false + EnvironmentEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + $ref: "#/components/schemas/EnvironmentDocument" + CreateExecutionRequest: + type: object + required: [type] + properties: + type: + type: string + enum: [http, batch, chain, load-test, sse, websocket] + request: + $ref: "#/components/schemas/ExecutionRequestItem" + requests: + type: array + items: + $ref: "#/components/schemas/ExecutionRequestItem" + filePath: + type: string + content: + type: string + environment: + type: string + options: + type: object + additionalProperties: {} + additionalProperties: false + ExecutionRequestItem: + type: object + required: [method, url] + properties: + name: + type: string + method: + type: string + url: + type: string + headers: + type: object + additionalProperties: + type: string + body: + type: string + additionalProperties: false + ExecutionEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + $ref: "#/components/schemas/Execution" + Execution: + type: object + required: [id, type, status, startedAt] + properties: + id: + type: string + type: + type: string + status: + type: string + enum: [queued, running, succeeded, failed, cancelled] + startedAt: + type: string + format: date-time + completedAt: + type: + - string + - "null" + format: date-time + result: + $ref: "#/components/schemas/ExecutionResult" + children: + type: array + items: + $ref: "#/components/schemas/Execution" + error: + type: string + additionalProperties: false + ExecutionResult: + type: object + properties: + response: + $ref: "#/components/schemas/ExecutionResponse" + statusCode: + type: integer + status: + type: integer + headers: + type: object + additionalProperties: + type: string + body: + type: string + durationMs: + type: integer + minimum: 0 + scriptLogs: + type: array + items: + type: string + assertions: + type: array + items: + $ref: "#/components/schemas/ExecutionAssertion" + total: + type: integer + succeeded: + type: integer + failed: + type: integer + totalRequests: + type: integer + concurrency: + type: integer + avgDurationMs: + type: integer + maxDurationMs: + type: integer + sessionType: + type: string + enum: [sse, websocket] + events: + type: array + items: + type: object + additionalProperties: + type: string + bodySample: + type: string + url: + type: string + sent: + type: array + items: + type: string + received: + type: array + items: + type: string + note: + type: string + cancelled: + type: boolean + error: + type: string + additionalProperties: true + ExecutionResponse: + type: object + required: [statusCode, status, headers, body, durationMs] + properties: + statusCode: + type: integer + status: + type: integer + headers: + type: object + additionalProperties: + type: string + body: + type: string + durationMs: + type: integer + minimum: 0 + additionalProperties: false + ExecutionAssertion: + type: object + required: [name, passed] + properties: + name: + type: string + expected: {} + actual: {} + passed: + type: boolean + additionalProperties: false + ExecutionHistoryEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/Execution" + additionalProperties: false + EventListEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [events] + properties: + events: + type: array + items: + $ref: "#/components/schemas/ExecutionEvent" + additionalProperties: false + ExecutionEvent: + type: object + required: [type, executionId, seq, timestamp, payload] + properties: + type: + type: string + executionId: + type: string + seq: + type: integer + minimum: 0 + timestamp: + type: string + format: date-time + payload: + type: object + additionalProperties: {} + additionalProperties: false + SignatureRequest: + type: object + required: [algorithm, data] + properties: + algorithm: + type: string + enum: [sha256, hmac-sha256] + data: + type: string + secret: + type: string + encoding: + type: string + enum: [hex, base64] + default: hex + additionalProperties: false + SignatureEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [algorithm, encoding, value] + properties: + algorithm: + type: string + encoding: + type: string + value: + type: string + additionalProperties: false + MockFileSaveRequest: + type: object + required: [rules] + properties: + workspace: + type: string + filePath: + type: string + rules: + type: array + items: + $ref: "#/components/schemas/MockRule" + additionalProperties: false + MockRule: + type: object + required: [id, name, enabled, priority, match, response] + properties: + id: + type: string + name: + type: string + enabled: + type: boolean + priority: + type: integer + tags: + type: array + items: + type: string + filePath: + type: string + meta: + type: object + additionalProperties: {} + match: + type: object + required: [method, path] + properties: + method: + type: string + path: + type: string + pathMode: + type: string + enum: [exact, prefix, regex] + additionalProperties: false + response: + type: object + required: [statusCode] + properties: + statusCode: + type: integer + headers: + type: object + additionalProperties: + type: string + body: {} + bodyType: + type: string + enum: [json, text, binary, file] + delayMs: + type: integer + additionalProperties: false + additionalProperties: false + MockListEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [rules] + properties: + rules: + type: array + items: + $ref: "#/components/schemas/MockRule" + filePath: + type: string + additionalProperties: false + WorkspaceRequest: + type: object + properties: + workspace: + type: string + additionalProperties: false + MockPreviewRequest: + type: object + required: [rules] + properties: + rules: + type: array + items: + $ref: "#/components/schemas/MockRule" + additionalProperties: false + MockReloadEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [rules, count] + properties: + rules: + type: array + items: + $ref: "#/components/schemas/MockRule" + count: + type: integer + additionalProperties: false + MockPreviewEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [created, updated, skipped] + properties: + created: + type: integer + updated: + type: integer + skipped: + type: integer + additionalProperties: false + MockServerRequest: + type: object + properties: + workspace: + type: string + port: + type: integer + additionalProperties: false + MockServerEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [running] + properties: + running: + type: boolean + port: + type: integer + baseUrl: + type: string + url: + type: string + workspace: + type: string + additionalProperties: false + MockHitLogEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [logs] + properties: + logs: + type: array + items: + $ref: "#/components/schemas/MockHitLog" + additionalProperties: false + MockHitLog: + type: object + required: [ruleId, method, path, statusCode, requestedAt] + properties: + ruleId: + type: string + method: + type: string + path: + type: string + statusCode: + type: integer + requestedAt: + type: string + format: date-time + additionalProperties: false + OpenApiImportRequest: + type: object + required: [path] + properties: + path: + type: string + baseUrlVariable: + type: string + default: baseUrl + additionalProperties: false + OpenApiImportEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [requests, mockRules, report] + properties: + requests: + type: string + mockRules: + type: array + items: + $ref: "#/components/schemas/MockRule" + report: + type: object + additionalProperties: {} + imported: + type: integer + additionalProperties: false + OpenApiDiffEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [added, changed, deleted, breaking] + properties: + added: + type: array + items: + type: string + changed: + type: array + items: + type: string + deleted: + type: array + items: + type: string + breaking: + type: array + items: + type: string + additionalProperties: false + OpenApiValidateEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [valid, errors] + properties: + valid: + type: boolean + errors: + type: array + items: + type: string + additionalProperties: false + CurlExportRequest: + type: object + required: [request] + properties: + request: + $ref: "#/components/schemas/ExecutionRequestItem" + additionalProperties: false + CurlExportEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [curl] + properties: + curl: + type: string + additionalProperties: false + CurlImportRequest: + type: object + required: [curl] + properties: + curl: + type: string + additionalProperties: false + CurlImportEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [request, requests] + properties: + request: + $ref: "#/components/schemas/ExecutionRequestItem" + requests: + type: string + additionalProperties: false + PostmanImportRequest: + type: object + required: [collection] + properties: + collection: + type: object + additionalProperties: {} + additionalProperties: false + PostmanImportEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [requests, imported] + properties: + requests: + type: string + imported: + type: integer + additionalProperties: false + IndexRebuildEnvelope: + allOf: + - $ref: "#/components/schemas/ApiEnvelope" + - type: object + properties: + data: + type: object + required: [rebuilt, indexes] + properties: + rebuilt: + type: boolean + indexes: + type: array + items: + type: string + additionalProperties: false diff --git a/fixtures/workspaces/demo/.http-client/environments.json b/fixtures/workspaces/demo/.http-client/environments.json new file mode 100644 index 0000000..4e3007b --- /dev/null +++ b/fixtures/workspaces/demo/.http-client/environments.json @@ -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" + } +} diff --git a/fixtures/workspaces/demo/.http-client/mocks/demo.mock.json b/fixtures/workspaces/demo/.http-client/mocks/demo.mock.json new file mode 100644 index 0000000..44886df --- /dev/null +++ b/fixtures/workspaces/demo/.http-client/mocks/demo.mock.json @@ -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 + } + } + ] +} diff --git a/fixtures/workspaces/demo/openapi/demo.yaml b/fixtures/workspaces/demo/openapi/demo.yaml new file mode 100644 index 0000000..542bfc2 --- /dev/null +++ b/fixtures/workspaces/demo/openapi/demo.yaml @@ -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. diff --git a/fixtures/workspaces/demo/requests.http b/fixtures/workspaces/demo/requests.http new file mode 100644 index 0000000..8605887 --- /dev/null +++ b/fixtures/workspaces/demo/requests.http @@ -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}} diff --git a/frontend/src/App.test.ts b/frontend/src/App.test.ts index 31cbd82..0d92380 100644 --- a/frontend/src/App.test.ts +++ b/frontend/src/App.test.ts @@ -1,12 +1,213 @@ -import { mount } from '@vue/test-utils' -import { describe, expect, it } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import App from './App.vue' +const parseResponse = { + requests: [ + { + id: 'req-1', + name: 'List users', + method: 'GET', + url: '{{baseUrl}}/users', + headers: { Accept: 'application/json' }, + }, + { + id: 'req-2', + name: 'Create user', + method: 'POST', + url: '{{baseUrl}}/users', + body: '{"name":"Ada"}', + }, + ], +} + +const executionResponse = { + id: 'exec-1', + status: 'completed', + response: { + status: 201, + headers: { 'content-type': 'application/json' }, + body: '{"ok":true}', + durationMs: 42, + }, +} + +function jsonResponse(data: unknown, init: ResponseInit = {}) { + return Promise.resolve( + new Response(JSON.stringify(data), { + headers: { 'content-type': 'application/json' }, + status: 200, + ...init, + }), + ) +} + describe('App', () => { - it('renders the application title', () => { + beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + const method = init?.method ?? 'GET' + + if (url === '/api/health') { + return jsonResponse({ status: 'ok' }) + } + + if (url === '/api/parse' && method === 'POST') { + return jsonResponse(parseResponse) + } + + if (url === '/api/executions' && method === 'POST') { + return jsonResponse(executionResponse) + } + + if (url === '/api/signatures/calculate' && method === 'POST') { + return jsonResponse({ signature: 'sha256-demo', canonicalRequest: 'GET\n/users' }) + } + + if (url === '/api/environments?workspace=demo-workspace') { + return jsonResponse({ + environments: { + local: { baseUrl: 'https://api.example.test', token: 'dev-token' }, + }, + defaultEnvironment: 'local', + }) + } + + if (url === '/api/environments' && method === 'POST') { + return jsonResponse({ saved: true }) + } + + if (url === '/api/mock-server/start' && method === 'POST') { + return jsonResponse({ id: 'mock-1', url: 'http://localhost:4010' }) + } + + if (url === '/api/openapi/import' && method === 'POST') { + return jsonResponse({ imported: 3 }) + } + + if (url === '/api/history') { + return jsonResponse({ items: [{ id: 'exec-1', status: 'succeeded' }] }) + } + + if (url === '/api/events') { + return jsonResponse({ events: [{ type: 'execution.started', executionId: 'exec-1' }] }) + } + + if (url === '/api/export/curl' && method === 'POST') { + return jsonResponse({ curl: "curl -X GET 'https://api.example.test/users'" }) + } + + if (url === '/api/import/postman' && method === 'POST') { + return jsonResponse({ requests: '# @name Imported\nGET https://api.example.test/users' }) + } + + return jsonResponse({ message: `Missing mock for ${method} ${url}` }, { status: 404 }) + }), + ) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders a three-column HTTP workbench and parses editable .http content', async () => { const wrapper = mount(App) - expect(wrapper.get('h1').text()).toBe('HTTP Client App') + await flushPromises() + + expect(wrapper.get('h1').text()).toContain('HTTP Client Workbench') + expect(wrapper.get('[data-testid="request-list-panel"]').text()).toContain('Requests') + expect(wrapper.get('[data-testid="editor-panel"]').text()).toContain('.http Editor') + expect(wrapper.get('[data-testid="response-panel"]').text()).toContain('Response') + expect(wrapper.get('[data-testid="health-status"]').text()).toContain('ok') + + await wrapper.get('[data-testid="parse-button"]').trigger('click') + await flushPromises() + + const requestList = wrapper.get('[data-testid="request-list"]') + expect(requestList.text()).toContain('List users') + expect(requestList.text()).toContain('GET') + expect(requestList.text()).toContain('{{baseUrl}}/users') + }) + + it('executes the selected request and renders response metadata', async () => { + const wrapper = mount(App) + + await wrapper.get('[data-testid="parse-button"]').trigger('click') + await flushPromises() + await wrapper.get('[data-testid="execute-button"]').trigger('click') + await flushPromises() + + const responsePanel = wrapper.get('[data-testid="response-panel"]') + expect(responsePanel.text()).toContain('201') + expect(responsePanel.text()).toContain('42 ms') + expect(responsePanel.text()).toContain('content-type') + expect(responsePanel.text()).toContain('"ok":true') + }) + + it('edits environments, previews variables, calculates signatures, and shows advanced API results', async () => { + const wrapper = mount(App) + + await flushPromises() + + await wrapper.get('[data-testid="load-environment-button"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="variable-preview"]').text()).toContain('baseUrl') + expect(wrapper.get('[data-testid="environment-select"]').element).toHaveProperty('value', 'local') + + await wrapper.get('[data-testid="save-environment-button"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="environment-status"]').text()).toContain('saved') + + await wrapper.get('[data-testid="signature-button"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="signature-result"]').text()).toContain('sha256-demo') + + await wrapper.get('[data-testid="advanced-mode"]').setValue('mock') + await wrapper.get('[data-testid="advanced-run"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('mock-1') + + await wrapper.get('[data-testid="advanced-mode"]').setValue('openapi') + await wrapper.get('[data-testid="advanced-run"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('imported') + + await wrapper.get('[data-testid="advanced-mode"]').setValue('curl') + await wrapper.get('[data-testid="advanced-run"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('curl -X GET') + + await wrapper.get('[data-testid="advanced-mode"]').setValue('history') + await wrapper.get('[data-testid="advanced-run"]').trigger('click') + await flushPromises() + expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('exec-1') + }) + + it('surfaces errors when optional backend APIs are unavailable', async () => { + vi.mocked(fetch).mockImplementation((input: RequestInfo | URL) => { + const url = String(input) + + if (url === '/api/health') { + return jsonResponse({ status: 'ok' }) + } + + if (url === '/api/mock-server/start') { + return jsonResponse({ error: 'mock API unavailable' }, { status: 501 }) + } + + return jsonResponse(parseResponse) + }) + + const wrapper = mount(App) + + await wrapper.get('[data-testid="advanced-mode"]').setValue('mock') + await wrapper.get('[data-testid="advanced-run"]').trigger('click') + await flushPromises() + + expect(wrapper.get('[data-testid="advanced-result"]').text()).toContain('mock API unavailable') }) }) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0a58122..71aefbf 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,28 +1,400 @@ + +