1158 lines
29 KiB
Go
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 ""
|
|
}
|