Files
http-client-app-plan/backend/internal/api/router.go
T

1158 lines
29 KiB
Go

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", 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
}
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 ""
}