package routeApi import ( "io" "net/http" "net/http/httptest" "net/url" "strings" "github.com/gin-gonic/gin" "github.com/yusing/godoxy/internal/common" "github.com/yusing/godoxy/internal/route/rules" apitypes "github.com/yusing/goutils/apitypes" gperr "github.com/yusing/goutils/errs" httputils "github.com/yusing/goutils/http" ) type RawRule struct { Name string `json:"name"` On string `json:"on"` Do string `json:"do"` } type PlaygroundRequest struct { Rules []RawRule `json:"rules" binding:"required"` MockRequest MockRequest `json:"mockRequest"` MockResponse MockResponse `json:"mockResponse"` } // @name PlaygroundRequest type MockRequest struct { Method string `json:"method"` Path string `json:"path"` Host string `json:"host"` Headers map[string][]string `json:"headers"` Query map[string][]string `json:"query"` Cookies []MockCookie `json:"cookies"` Body string `json:"body"` RemoteIP string `json:"remoteIP"` } // @name MockRequest type MockCookie struct { Name string `json:"name"` Value string `json:"value"` } // @name MockCookie type MockResponse struct { StatusCode int `json:"statusCode"` Headers map[string][]string `json:"headers"` Body string `json:"body"` } // @name MockResponse type PlaygroundResponse struct { ParsedRules []ParsedRule `json:"parsedRules"` MatchedRules []string `json:"matchedRules"` FinalRequest FinalRequest `json:"finalRequest"` FinalResponse FinalResponse `json:"finalResponse"` ExecutionError gperr.Error `json:"executionError,omitempty"` UpstreamCalled bool `json:"upstreamCalled"` } // @name PlaygroundResponse type ParsedRule struct { Name string `json:"name"` On string `json:"on"` Do string `json:"do"` ValidationError gperr.Error `json:"validationError,omitempty"` IsResponseRule bool `json:"isResponseRule"` } // @name ParsedRule type FinalRequest struct { Method string `json:"method"` Path string `json:"path"` Host string `json:"host"` Headers map[string][]string `json:"headers"` Query map[string][]string `json:"query"` Body string `json:"body"` } // @name FinalRequest type FinalResponse struct { StatusCode int `json:"statusCode"` Headers map[string][]string `json:"headers"` Body string `json:"body"` } // @name FinalResponse // @x-id "playground" // @BasePath /api/v1 // @Summary Rule Playground // @Description Test rules against mock request/response // @Tags route // @Accept json // @Produce json // @Param request body PlaygroundRequest true "Playground request" // @Success 200 {object} PlaygroundResponse // @Failure 400 {object} apitypes.ErrorResponse // @Failure 403 {object} apitypes.ErrorResponse // @Router /route/playground [post] func Playground(c *gin.Context) { var req PlaygroundRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, apitypes.Error("invalid request", err)) return } // Apply defaults if req.MockRequest.Method == "" { req.MockRequest.Method = "GET" } if req.MockRequest.Path == "" { req.MockRequest.Path = "/" } if req.MockRequest.Host == "" { req.MockRequest.Host = "localhost" } // Parse rules parsedRules, rulesList, parseErr := parseRules(req.Rules) // Create mock HTTP request mockReq := createMockRequest(req.MockRequest) // Create mock HTTP response writer recorder := httptest.NewRecorder() // Set initial mock response if provided if req.MockResponse.StatusCode > 0 { recorder.Code = req.MockResponse.StatusCode } if req.MockResponse.Headers != nil { for k, values := range req.MockResponse.Headers { for _, v := range values { recorder.Header().Add(k, v) } } } if req.MockResponse.Body != "" { recorder.Body.WriteString(req.MockResponse.Body) } // Execute rules matchedRules := []string{} upstreamCalled := false var executionError gperr.Error // Variables to capture modified request state var finalReqMethod, finalReqPath, finalReqHost string var finalReqHeaders http.Header var finalReqQuery url.Values if parseErr == nil && len(rulesList) > 0 { // Create upstream handler that records if it was called and captures request state upstreamHandler := func(w http.ResponseWriter, r *http.Request) { upstreamCalled = true // Capture the request state when upstream is called finalReqMethod = r.Method finalReqPath = r.URL.Path finalReqHost = r.Host finalReqHeaders = r.Header.Clone() finalReqQuery = r.URL.Query() // Debug: also check RequestURI if r.URL.Path != r.URL.RawPath && r.URL.RawPath != "" { finalReqPath = r.URL.RawPath } // If there's mock response body, write it during upstream call if req.MockResponse.Body != "" && w.Header().Get("Content-Type") == "" { w.Header().Set("Content-Type", "text/plain") } if req.MockResponse.StatusCode > 0 { w.WriteHeader(req.MockResponse.StatusCode) } if req.MockResponse.Body != "" { w.Write([]byte(req.MockResponse.Body)) } } // Build handler with rules handler := rulesList.BuildHandler(upstreamHandler) // Execute the handler handlerWithRecover(recorder, mockReq, handler, &executionError) // Track which rules matched // Since we can't easily instrument the rules, we'll check each rule manually matchedRules = checkMatchedRules(rulesList, recorder, mockReq) } else if parseErr != nil { executionError = parseErr } // Build final request state // Use captured state if upstream was called, otherwise use current state var finalRequest FinalRequest if upstreamCalled { finalRequest = FinalRequest{ Method: finalReqMethod, Path: finalReqPath, Host: finalReqHost, Headers: finalReqHeaders, Query: finalReqQuery, Body: req.MockRequest.Body, } } else { finalRequest = FinalRequest{ Method: mockReq.Method, Path: mockReq.URL.Path, Host: mockReq.Host, Headers: mockReq.Header, Query: mockReq.URL.Query(), Body: req.MockRequest.Body, } } // Build final response state finalResponse := FinalResponse{ StatusCode: recorder.Code, Headers: recorder.Header(), Body: recorder.Body.String(), } // Ensure status code defaults to 200 if not set if finalResponse.StatusCode == 0 { finalResponse.StatusCode = http.StatusOK } // prevent null in response if parsedRules == nil { parsedRules = []ParsedRule{} } if matchedRules == nil { matchedRules = []string{} } response := PlaygroundResponse{ ParsedRules: parsedRules, MatchedRules: matchedRules, FinalRequest: finalRequest, FinalResponse: finalResponse, ExecutionError: executionError, UpstreamCalled: upstreamCalled, } if common.IsTest { c.Set("response", response) } c.JSON(http.StatusOK, response) } func handlerWithRecover(w http.ResponseWriter, r *http.Request, h http.HandlerFunc, outErr *gperr.Error) { defer func() { if r := recover(); r != nil { if outErr != nil { *outErr = gperr.Errorf("panic during rule execution: %v", r) } } }() h(w, r) } func parseRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, gperr.Error) { var parsedRules []ParsedRule var rulesList rules.Rules // Parse each rule individually to capture per-rule errors for _, rawRule := range rawRules { var rule rules.Rule // Extract fields name := rawRule.Name onStr := rawRule.On doStr := rawRule.Do rule.Name = name // Parse On var onErr error if onStr != "" { onErr = rule.On.Parse(onStr) } // Parse Do var doErr error if doStr != "" { doErr = rule.Do.Parse(doStr) } // Determine if valid isValid := onErr == nil && doErr == nil validationErr := gperr.Join(gperr.PrependSubject("on", onErr), gperr.PrependSubject("do", doErr)) parsedRules = append(parsedRules, ParsedRule{ Name: name, On: onStr, Do: doStr, ValidationError: validationErr, IsResponseRule: rule.IsResponseRule(), }) // Only add valid rules to execution list if isValid { rulesList = append(rulesList, rule) } } return parsedRules, rulesList, nil } func createMockRequest(mock MockRequest) *http.Request { // Create URL urlStr := mock.Path if len(mock.Query) > 0 { query := url.Values(mock.Query) urlStr = mock.Path + "?" + query.Encode() } // Create request var body io.Reader if mock.Body != "" { body = strings.NewReader(mock.Body) } req := httptest.NewRequest(mock.Method, urlStr, body) // Set host req.Host = mock.Host // Set headers req.Header = mock.Headers // Set cookies if mock.Cookies != nil { for _, cookie := range mock.Cookies { req.AddCookie(&http.Cookie{ Name: cookie.Name, Value: cookie.Value, }) } } // Set remote address if mock.RemoteIP != "" { req.RemoteAddr = mock.RemoteIP + ":0" } else { req.RemoteAddr = "127.0.0.1:0" } return req } func checkMatchedRules(rulesList rules.Rules, w http.ResponseWriter, r *http.Request) []string { var matched []string // Create a ResponseModifier to properly check rules rm := httputils.NewResponseModifier(w) for _, rule := range rulesList { // Check if rule matches if rule.Check(rm, r) { matched = append(matched, rule.Name) } } return matched }