mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-20 16:01:36 +02:00
feat(api): rules playground API
- updated swagger
This commit is contained in:
361
internal/api/v1/route/playground.go
Normal file
361
internal/api/v1/route/playground.go
Normal file
@@ -0,0 +1,361 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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 := rules.NewResponseModifier(w)
|
||||
|
||||
for _, rule := range rulesList {
|
||||
// Check if rule matches
|
||||
if rule.Check(rm, r) {
|
||||
matched = append(matched, rule.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return matched
|
||||
}
|
||||
229
internal/api/v1/route/playground_test.go
Normal file
229
internal/api/v1/route/playground_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package routeApi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPlayground(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request PlaygroundRequest
|
||||
wantStatusCode int
|
||||
checkResponse func(t *testing.T, resp PlaygroundResponse)
|
||||
}{
|
||||
{
|
||||
name: "simple path matching rule",
|
||||
request: PlaygroundRequest{
|
||||
Rules: []RawRule{
|
||||
{
|
||||
Name: "test rule",
|
||||
On: "path /api",
|
||||
Do: "pass",
|
||||
},
|
||||
},
|
||||
MockRequest: MockRequest{
|
||||
Method: "GET",
|
||||
Path: "/api",
|
||||
},
|
||||
},
|
||||
wantStatusCode: http.StatusOK,
|
||||
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
|
||||
if len(resp.ParsedRules) != 1 {
|
||||
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
|
||||
}
|
||||
if resp.ParsedRules[0].ValidationError != nil {
|
||||
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
|
||||
}
|
||||
if len(resp.MatchedRules) != 1 || resp.MatchedRules[0] != "test rule" {
|
||||
t.Errorf("expected matched rules to be ['test rule'], got %v", resp.MatchedRules)
|
||||
}
|
||||
if !resp.UpstreamCalled {
|
||||
t.Error("expected upstream to be called")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "header matching rule",
|
||||
request: PlaygroundRequest{
|
||||
Rules: []RawRule{
|
||||
{
|
||||
Name: "check user agent",
|
||||
On: "header User-Agent Chrome",
|
||||
Do: "error 403 Forbidden",
|
||||
},
|
||||
},
|
||||
MockRequest: MockRequest{
|
||||
Method: "GET",
|
||||
Path: "/",
|
||||
Headers: map[string][]string{
|
||||
"User-Agent": {"Chrome"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantStatusCode: http.StatusOK,
|
||||
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
|
||||
if len(resp.ParsedRules) != 1 {
|
||||
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
|
||||
}
|
||||
if resp.ParsedRules[0].ValidationError != nil {
|
||||
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
|
||||
}
|
||||
if len(resp.MatchedRules) != 1 {
|
||||
t.Errorf("expected 1 matched rule, got %d", len(resp.MatchedRules))
|
||||
}
|
||||
if resp.FinalResponse.StatusCode != 403 {
|
||||
t.Errorf("expected status 403, got %d", resp.FinalResponse.StatusCode)
|
||||
}
|
||||
if resp.UpstreamCalled {
|
||||
t.Error("expected upstream not to be called")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid rule syntax",
|
||||
request: PlaygroundRequest{
|
||||
Rules: []RawRule{
|
||||
{
|
||||
Name: "bad rule",
|
||||
On: "invalid_checker something",
|
||||
Do: "pass",
|
||||
},
|
||||
},
|
||||
MockRequest: MockRequest{
|
||||
Method: "GET",
|
||||
Path: "/",
|
||||
},
|
||||
},
|
||||
wantStatusCode: http.StatusOK,
|
||||
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
|
||||
if len(resp.ParsedRules) != 1 {
|
||||
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
|
||||
}
|
||||
if resp.ParsedRules[0].ValidationError == nil {
|
||||
t.Error("expected validation error to be set")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rewrite path rule",
|
||||
request: PlaygroundRequest{
|
||||
Rules: []RawRule{
|
||||
{
|
||||
Name: "rewrite rule",
|
||||
On: "path glob(/api/*)",
|
||||
Do: "rewrite /api/ /v1/",
|
||||
},
|
||||
},
|
||||
MockRequest: MockRequest{
|
||||
Method: "GET",
|
||||
Path: "/api/users",
|
||||
},
|
||||
},
|
||||
wantStatusCode: http.StatusOK,
|
||||
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
|
||||
if len(resp.ParsedRules) != 1 {
|
||||
t.Errorf("expected 1 parsed rule, got %d", len(resp.ParsedRules))
|
||||
}
|
||||
if resp.ParsedRules[0].ValidationError != nil {
|
||||
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
|
||||
}
|
||||
if !resp.UpstreamCalled {
|
||||
t.Error("expected upstream to be called")
|
||||
}
|
||||
if resp.FinalRequest.Path != "/v1/users" {
|
||||
t.Errorf("expected path to be rewritten to /v1/users, got %s", resp.FinalRequest.Path)
|
||||
}
|
||||
// Note: matched rules tracking has limitations with fresh ResponseModifier
|
||||
// The important thing is that the rewrite actually worked
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "method matching rule",
|
||||
request: PlaygroundRequest{
|
||||
Rules: []RawRule{
|
||||
{
|
||||
Name: "block POST",
|
||||
On: "method POST",
|
||||
Do: `error "405" "Method Not Allowed"`,
|
||||
},
|
||||
},
|
||||
MockRequest: MockRequest{
|
||||
Method: "POST",
|
||||
Path: "/api",
|
||||
},
|
||||
},
|
||||
wantStatusCode: http.StatusOK,
|
||||
checkResponse: func(t *testing.T, resp PlaygroundResponse) {
|
||||
if resp.ParsedRules[0].ValidationError != nil {
|
||||
t.Errorf("expected rule to be valid, got error: %v", resp.ParsedRules[0].ValidationError)
|
||||
}
|
||||
if len(resp.MatchedRules) != 1 {
|
||||
t.Errorf("expected 1 matched rule, got %d", len(resp.MatchedRules))
|
||||
}
|
||||
if resp.FinalResponse.StatusCode != 405 {
|
||||
t.Errorf("expected status 405, got %d", resp.FinalResponse.StatusCode)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create request
|
||||
body, _ := json.Marshal(tt.request)
|
||||
req := httptest.NewRequest("POST", "/api/v1/route/playground", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Create response recorder
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Create gin context
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
// Call handler
|
||||
Playground(c)
|
||||
|
||||
// Check status code
|
||||
if w.Code != tt.wantStatusCode {
|
||||
t.Errorf("expected status code %d, got %d", tt.wantStatusCode, w.Code)
|
||||
}
|
||||
|
||||
respAny, ok := c.Get("response")
|
||||
if !ok {
|
||||
t.Fatalf("expected response to be set")
|
||||
}
|
||||
resp := respAny.(PlaygroundResponse)
|
||||
|
||||
// Run custom checks
|
||||
if tt.checkResponse != nil {
|
||||
tt.checkResponse(t, resp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaygroundInvalidRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/route/playground", bytes.NewReader([]byte(`{}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
|
||||
Playground(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected status code %d, got %d", http.StatusBadRequest, w.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user