Files
summercms/modules/tide/rules.go
Jakub Zych 5e50b166ef refactor(10.2-01): nest framework packages under modules
- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
2026-09-28 02:21:02 +02:00

148 lines
3.6 KiB
Go

package tide
import (
"bytes"
"fmt"
"net/http"
"os"
"path"
"strings"
"github.com/goccy/go-yaml"
)
const (
ClientNuxt = "nuxt"
ClientMCP = "mcp"
SessionHeader = "X-Parity-Session"
)
// Rules is the committed, non-secret capture policy for a proxy session.
type Rules struct {
Client string `yaml:"client"`
KeepRequestHeaders []string `yaml:"keep_request_headers"`
KeepResponseHeaders []string `yaml:"keep_response_headers"`
Routes []RouteRule `yaml:"routes"`
}
// RouteRule matches a method and path pattern and names capture sources.
type RouteRule struct {
Method string `yaml:"method"`
Path string `yaml:"path"`
KeepRequestHeaders []string `yaml:"keep_request_headers,omitempty"`
KeepResponseHeaders []string `yaml:"keep_response_headers,omitempty"`
Capture []CaptureRule `yaml:"capture,omitempty"`
}
// LoadRules reads a strict YAML rule file, rejecting unknown fields.
func LoadRules(path string) (Rules, error) {
raw, err := os.ReadFile(path)
if err != nil {
return Rules{}, fmt.Errorf("tide: read rules %s: %w", path, err)
}
return ParseRules(raw)
}
// ParseRules decodes rules YAML with unknown-field rejection.
func ParseRules(raw []byte) (Rules, error) {
var rules Rules
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
if err := dec.Decode(&rules); err != nil {
return Rules{}, fmt.Errorf("tide: parse rules: %w", err)
}
if err := validateRules(rules); err != nil {
return Rules{}, err
}
return rules, nil
}
func validateRules(rules Rules) error {
switch rules.Client {
case ClientNuxt, ClientMCP:
default:
return fmt.Errorf("tide: rules client must be %q or %q", ClientNuxt, ClientMCP)
}
for i, route := range rules.Routes {
if strings.TrimSpace(route.Method) == "" {
return fmt.Errorf("tide: rules routes[%d] is missing method", i)
}
if strings.TrimSpace(route.Path) == "" {
return fmt.Errorf("tide: rules routes[%d] is missing path", i)
}
for j, cap := range route.Capture {
if strings.TrimSpace(cap.As) == "" {
return fmt.Errorf("tide: rules routes[%d].capture[%d] is missing as", i, j)
}
}
}
return nil
}
// Match returns the first route rule for method and path, or nil.
func (r Rules) Match(method, requestPath string) *RouteRule {
for i := range r.Routes {
route := &r.Routes[i]
if !matchMethod(route.Method, method) {
continue
}
if !matchPath(route.Path, requestPath) {
continue
}
return route
}
return nil
}
func (r Rules) requestHeaders(route *RouteRule) []string {
if route != nil && len(route.KeepRequestHeaders) > 0 {
return route.KeepRequestHeaders
}
return r.KeepRequestHeaders
}
func (r Rules) responseHeaders(route *RouteRule) []string {
if route != nil && len(route.KeepResponseHeaders) > 0 {
return route.KeepResponseHeaders
}
return r.KeepResponseHeaders
}
func matchMethod(pattern, method string) bool {
pattern = strings.TrimSpace(pattern)
if pattern == "" || pattern == "*" {
return true
}
return strings.EqualFold(pattern, method)
}
func matchPath(pattern, requestPath string) bool {
if pattern == requestPath {
return true
}
ok, err := path.Match(pattern, requestPath)
return err == nil && ok
}
func filterHeaders(h http.Header, keep []string) map[string]string {
if len(keep) == 0 || h == nil {
return nil
}
out := make(map[string]string)
for _, name := range keep {
vals := h.Values(name)
if len(vals) == 0 {
continue
}
if len(vals) == 1 {
out[http.CanonicalHeaderKey(name)] = vals[0]
continue
}
out[http.CanonicalHeaderKey(name)] = strings.Join(vals, "\n")
}
if len(out) == 0 {
return nil
}
return out
}