mirror of
https://github.com/yusing/godoxy.git
synced 2026-03-17 23:03:49 +01:00
Update playground request to take rules as a string and parse either YAML list or DSL config, with tests and swagger updates.
397 lines
10 KiB
Go
397 lines
10 KiB
Go
package routeApi
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/goccy/go-yaml"
|
|
"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 string `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 error `json:"executionError,omitempty"` // we need the structured error, not the plain string
|
|
UpstreamCalled bool `json:"upstreamCalled"`
|
|
} // @name PlaygroundResponse
|
|
|
|
type ParsedRule struct {
|
|
Name string `json:"name"`
|
|
On string `json:"on"`
|
|
Do string `json:"do"`
|
|
ValidationError error `json:"validationError,omitempty"` // we need the structured error, not the plain string
|
|
} // @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 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 *error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
if outErr != nil {
|
|
*outErr = fmt.Errorf("panic during rule execution: %v", r)
|
|
}
|
|
}
|
|
}()
|
|
h(w, r)
|
|
}
|
|
|
|
func parseRules(config string) ([]ParsedRule, rules.Rules, error) {
|
|
config = strings.TrimSpace(config)
|
|
if config == "" {
|
|
return []ParsedRule{}, nil, nil
|
|
}
|
|
|
|
var rawRules []RawRule
|
|
if err := yaml.Unmarshal([]byte(config), &rawRules); err == nil && len(rawRules) > 0 {
|
|
return parseRawRules(rawRules)
|
|
}
|
|
|
|
var rulesList rules.Rules
|
|
if err := rulesList.Parse(config); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
parsedRules := make([]ParsedRule, 0, len(rulesList))
|
|
for _, rule := range rulesList {
|
|
parsedRules = append(parsedRules, ParsedRule{
|
|
Name: rule.Name,
|
|
On: rule.On.String(),
|
|
Do: rule.Do.String(),
|
|
})
|
|
}
|
|
|
|
return parsedRules, rulesList, nil
|
|
}
|
|
|
|
func parseRawRules(rawRules []RawRule) ([]ParsedRule, rules.Rules, error) {
|
|
parsedRules := make([]ParsedRule, 0, len(rawRules))
|
|
rulesList := make(rules.Rules, 0, len(rawRules))
|
|
|
|
var valErrs gperr.Builder
|
|
|
|
// 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
|
|
var validationErr error
|
|
if !isValid {
|
|
validationErr = gperr.Join(gperr.PrependSubject(onErr, "on"), gperr.PrependSubject(doErr, "do"))
|
|
valErrs.Add(validationErr)
|
|
}
|
|
|
|
parsedRules = append(parsedRules, ParsedRule{
|
|
Name: name,
|
|
On: onStr,
|
|
Do: doStr,
|
|
ValidationError: validationErr,
|
|
})
|
|
|
|
// Only add valid rules to execution list
|
|
if isValid {
|
|
rulesList = append(rulesList, rule)
|
|
}
|
|
}
|
|
|
|
return parsedRules, rulesList, valErrs.Error()
|
|
}
|
|
|
|
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
|
|
}
|