mirror of
https://github.com/yusing/godoxy.git
synced 2026-04-24 09:48:49 +02:00
feat(rules): add post-request rules system with response manipulation (#160)
* Add comprehensive post-request rules support for response phase * Enable response body, status, and header manipulation via set commands * Refactor command handlers to support both request and response phases * Implement response modifier system for post-request template execution * Support response-based rule matching with status and header checks * Add comprehensive benchmarks for matcher performance * Refactor authentication and proxying commands for unified error handling * Support negated conditions with ! * Enhance error handling, error formatting and validation * Routes: add `rule_file` field with rule preset support * Environment variable substitution: now supports variables without `GODOXY_` prefix * new conditions: * `on resp_header <key> [<value>]` * `on status <status>` * new commands: * `require_auth` * `set resp_header <key> <template>` * `set resp_body <template>` * `set status <code>` * `log <level> <path> <template>` * `notify <level> <provider> <title_template> <body_template>`
This commit is contained in:
@@ -6,10 +6,11 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/gobwas/glob"
|
||||
"github.com/rs/zerolog"
|
||||
nettypes "github.com/yusing/godoxy/internal/net/types"
|
||||
gperr "github.com/yusing/goutils/errs"
|
||||
httputils "github.com/yusing/goutils/http"
|
||||
@@ -21,6 +22,17 @@ type (
|
||||
First T1
|
||||
Second T2
|
||||
}
|
||||
Tuple3[T1, T2, T3 any] struct {
|
||||
First T1
|
||||
Second T2
|
||||
Third T3
|
||||
}
|
||||
Tuple4[T1, T2, T3, T4 any] struct {
|
||||
First T1
|
||||
Second T2
|
||||
Third T3
|
||||
Fourth T4
|
||||
}
|
||||
StrTuple = Tuple[string, string]
|
||||
IntTuple = Tuple[int, int]
|
||||
MapValueMatcher = Tuple[string, Matcher]
|
||||
@@ -30,97 +42,24 @@ func (t *Tuple[T1, T2]) Unpack() (T1, T2) {
|
||||
return t.First, t.Second
|
||||
}
|
||||
|
||||
func (t *Tuple3[T1, T2, T3]) Unpack() (T1, T2, T3) {
|
||||
return t.First, t.Second, t.Third
|
||||
}
|
||||
|
||||
func (t *Tuple4[T1, T2, T3, T4]) Unpack() (T1, T2, T3, T4) {
|
||||
return t.First, t.Second, t.Third, t.Fourth
|
||||
}
|
||||
|
||||
func (t *Tuple[T1, T2]) String() string {
|
||||
return fmt.Sprintf("%v:%v", t.First, t.Second)
|
||||
}
|
||||
|
||||
type (
|
||||
Matcher func(string) bool
|
||||
MatcherType string
|
||||
)
|
||||
|
||||
const (
|
||||
MatcherTypeString MatcherType = "string"
|
||||
MatcherTypeGlob MatcherType = "glob"
|
||||
MatcherTypeRegex MatcherType = "regex"
|
||||
)
|
||||
|
||||
func unquoteExpr(s string) (string, gperr.Error) {
|
||||
if s == "" {
|
||||
return "", nil
|
||||
}
|
||||
switch s[0] {
|
||||
case '"', '\'', '`':
|
||||
if s[0] != s[len(s)-1] {
|
||||
return "", ErrUnterminatedQuotes
|
||||
}
|
||||
return s[1 : len(s)-1], nil
|
||||
default:
|
||||
return s, nil
|
||||
}
|
||||
func (t *Tuple3[T1, T2, T3]) String() string {
|
||||
return fmt.Sprintf("%v:%v:%v", t.First, t.Second, t.Third)
|
||||
}
|
||||
|
||||
func ExtractExpr(s string) (matcherType MatcherType, expr string, err gperr.Error) {
|
||||
idx := strings.IndexByte(s, '(')
|
||||
if idx == -1 {
|
||||
return MatcherTypeString, s, nil
|
||||
}
|
||||
idxEnd := strings.LastIndexByte(s, ')')
|
||||
if idxEnd == -1 {
|
||||
return "", "", ErrUnterminatedBrackets
|
||||
}
|
||||
|
||||
expr, err = unquoteExpr(s[idx+1 : idxEnd])
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
matcherType = MatcherType(strings.ToLower(s[:idx]))
|
||||
|
||||
switch matcherType {
|
||||
case MatcherTypeGlob, MatcherTypeRegex, MatcherTypeString:
|
||||
return
|
||||
default:
|
||||
return "", "", ErrInvalidArguments.Withf("invalid matcher type: %s", matcherType)
|
||||
}
|
||||
}
|
||||
|
||||
func ParseMatcher(expr string) (Matcher, gperr.Error) {
|
||||
t, expr, err := ExtractExpr(expr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch t {
|
||||
case MatcherTypeString:
|
||||
return StringMatcher(expr)
|
||||
case MatcherTypeGlob:
|
||||
return GlobMatcher(expr)
|
||||
case MatcherTypeRegex:
|
||||
return RegexMatcher(expr)
|
||||
}
|
||||
// won't reach here
|
||||
return nil, ErrInvalidArguments.Withf("invalid matcher type: %s", t)
|
||||
}
|
||||
|
||||
func StringMatcher(s string) (Matcher, gperr.Error) {
|
||||
return func(s2 string) bool {
|
||||
return s == s2
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GlobMatcher(expr string) (Matcher, gperr.Error) {
|
||||
g, err := glob.Compile(expr)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
return g.Match, nil
|
||||
}
|
||||
|
||||
func RegexMatcher(expr string) (Matcher, gperr.Error) {
|
||||
re, err := regexp.Compile(expr)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
return re.MatchString, nil
|
||||
func (t *Tuple4[T1, T2, T3, T4]) String() string {
|
||||
return fmt.Sprintf("%v:%v:%v:%v", t.First, t.Second, t.Third, t.Fourth)
|
||||
}
|
||||
|
||||
// validateSingleMatcher returns Matcher with the matcher validated.
|
||||
@@ -131,14 +70,6 @@ func validateSingleMatcher(args []string) (any, gperr.Error) {
|
||||
return ParseMatcher(args[0])
|
||||
}
|
||||
|
||||
// toStrTuple returns *StrTuple.
|
||||
func toStrTuple(args []string) (any, gperr.Error) {
|
||||
if len(args) != 2 {
|
||||
return nil, ErrExpectTwoArgs
|
||||
}
|
||||
return &StrTuple{args[0], args[1]}, nil
|
||||
}
|
||||
|
||||
// toKVOptionalVMatcher returns *MapValueMatcher that value is optional.
|
||||
func toKVOptionalVMatcher(args []string) (any, gperr.Error) {
|
||||
switch len(args) {
|
||||
@@ -155,6 +86,18 @@ func toKVOptionalVMatcher(args []string) (any, gperr.Error) {
|
||||
}
|
||||
}
|
||||
|
||||
func toKeyValueTemplate(args []string) (any, gperr.Error) {
|
||||
if len(args) != 2 {
|
||||
return nil, ErrExpectTwoArgs
|
||||
}
|
||||
|
||||
tmpl, err := validateTemplate(args[1], false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &keyValueTemplate{args[0], tmpl}, nil
|
||||
}
|
||||
|
||||
// validateURL returns types.URL with the URL validated.
|
||||
func validateURL(args []string) (any, gperr.Error) {
|
||||
if len(args) != 1 {
|
||||
@@ -164,6 +107,12 @@ func validateURL(args []string) (any, gperr.Error) {
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
// expect relative URL, must starts with /
|
||||
if !strings.HasPrefix(u.Path, "/") {
|
||||
return nil, ErrInvalidArguments.Withf("relative URL must starts with /")
|
||||
}
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
@@ -250,6 +199,57 @@ func validateMethod(args []string) (any, gperr.Error) {
|
||||
return method, nil
|
||||
}
|
||||
|
||||
func validateStatusCode(status string) (int, error) {
|
||||
statusCode, err := strconv.Atoi(status)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if statusCode < 100 || statusCode > 599 {
|
||||
return 0, fmt.Errorf("status code out of range: %s", status)
|
||||
}
|
||||
return statusCode, nil
|
||||
}
|
||||
|
||||
// validateStatusRange returns Tuple[int, int] with the status range validated.
|
||||
// accepted formats are:
|
||||
// - <status>
|
||||
// - <status>-<status>
|
||||
// - 1xx
|
||||
// - 2xx
|
||||
// - 3xx
|
||||
// - 4xx
|
||||
// - 5xx
|
||||
func validateStatusRange(args []string) (any, gperr.Error) {
|
||||
if len(args) != 1 {
|
||||
return nil, ErrExpectOneArg
|
||||
}
|
||||
|
||||
beg, end, ok := strings.Cut(args[0], "-")
|
||||
if !ok { // <status>
|
||||
end = beg
|
||||
}
|
||||
|
||||
switch beg {
|
||||
case "1xx":
|
||||
return &IntTuple{100, 199}, nil
|
||||
case "2xx":
|
||||
return &IntTuple{200, 299}, nil
|
||||
case "3xx":
|
||||
return &IntTuple{300, 399}, nil
|
||||
case "4xx":
|
||||
return &IntTuple{400, 499}, nil
|
||||
case "5xx":
|
||||
return &IntTuple{500, 599}, nil
|
||||
}
|
||||
|
||||
begInt, begErr := validateStatusCode(beg)
|
||||
endInt, endErr := validateStatusCode(end)
|
||||
if begErr != nil || endErr != nil {
|
||||
return nil, ErrInvalidArguments.With(gperr.Join(begErr, endErr))
|
||||
}
|
||||
return &IntTuple{begInt, endInt}, nil
|
||||
}
|
||||
|
||||
// validateUserBCryptPassword returns *HashedCrendential with the password validated.
|
||||
func validateUserBCryptPassword(args []string) (any, gperr.Error) {
|
||||
if len(args) != 2 {
|
||||
@@ -260,20 +260,77 @@ func validateUserBCryptPassword(args []string) (any, gperr.Error) {
|
||||
|
||||
// validateModField returns CommandHandler with the field validated.
|
||||
func validateModField(mod FieldModifier, args []string) (CommandHandler, gperr.Error) {
|
||||
if len(args) == 0 {
|
||||
return nil, ErrExpectTwoOrThreeArgs
|
||||
}
|
||||
setField, ok := modFields[args[0]]
|
||||
if !ok {
|
||||
return nil, ErrInvalidSetTarget.Subject(args[0])
|
||||
return nil, ErrUnknownModField.Subject(args[0])
|
||||
}
|
||||
if mod == ModFieldRemove {
|
||||
if len(args) != 2 {
|
||||
return nil, ErrExpectTwoArgs
|
||||
}
|
||||
// setField expect validateStrTuple
|
||||
args = append(args, "")
|
||||
}
|
||||
validArgs, err := setField.validate(args[1:])
|
||||
if err != nil {
|
||||
return nil, err.Withf(setField.help.String())
|
||||
return nil, err.With(setField.help.Error())
|
||||
}
|
||||
modder := setField.builder(validArgs)
|
||||
switch mod {
|
||||
case ModFieldAdd:
|
||||
return modder.add, nil
|
||||
add := modder.add
|
||||
if add == nil {
|
||||
return nil, ErrInvalidArguments.Withf("add is not supported for %s", mod)
|
||||
}
|
||||
return add, nil
|
||||
case ModFieldRemove:
|
||||
return modder.remove, nil
|
||||
remove := modder.remove
|
||||
if remove == nil {
|
||||
return nil, ErrInvalidArguments.Withf("remove is not supported for %s", mod)
|
||||
}
|
||||
return remove, nil
|
||||
}
|
||||
return modder.set, nil
|
||||
set := modder.set
|
||||
if set == nil {
|
||||
return nil, ErrInvalidArguments.Withf("set is not supported for %s", mod)
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
func isTemplate(tmplStr string) bool {
|
||||
return strings.Contains(tmplStr, "{{")
|
||||
}
|
||||
|
||||
func validateTemplate(tmplStr string, newline bool) (templateOrStr, gperr.Error) {
|
||||
if newline && !strings.HasSuffix(tmplStr, "\n") {
|
||||
tmplStr += "\n"
|
||||
}
|
||||
|
||||
if !isTemplate(tmplStr) {
|
||||
return strTemplate(tmplStr), nil
|
||||
}
|
||||
|
||||
tmpl, err := template.New("template").Parse(tmplStr)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidArguments.With(err)
|
||||
}
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func validateLevel(level string) (zerolog.Level, gperr.Error) {
|
||||
l, err := zerolog.ParseLevel(level)
|
||||
if err != nil {
|
||||
return zerolog.NoLevel, ErrInvalidArguments.With(err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// func validateNotifProvider(provider string) gperr.Error {
|
||||
// if !notif.HasProvider(provider) {
|
||||
// return ErrInvalidArguments.Subject(provider)
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
|
||||
Reference in New Issue
Block a user