Files
summercms/tide/flow.go
2026-09-17 14:43:01 +02:00

220 lines
5.7 KiB
Go

package tide
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
)
const CurrentVersion = 1
// DefaultMaxBody is the default cap on recorded or replayed HTTP bodies.
const DefaultMaxBody = 8 << 20
// Flow is a versioned ordered list of HTTP steps.
type Flow struct {
Version int `yaml:"version"`
Name string `yaml:"name"`
Description string `yaml:"description,omitempty"`
SeedHook string `yaml:"seed_hook,omitempty"`
Steps []Step `yaml:"steps"`
}
// Step is one request/response pair in a flow.
type Step struct {
ID string `yaml:"id"`
RouteID string `yaml:"route_id,omitempty"`
Request Request `yaml:"request"`
Response Response `yaml:"response"`
Capture []CaptureRule `yaml:"capture,omitempty"`
Normalize []NormalizeRule `yaml:"normalize,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
}
// Request is the outbound HTTP call for a step.
type Request struct {
Method string `yaml:"method"`
Path string `yaml:"path"`
Query string `yaml:"query,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Body Body `yaml:"body,omitempty"`
}
// Response is the recorded or expected HTTP reply.
type Response struct {
Status int `yaml:"status,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Body Body `yaml:"body,omitempty"`
BodyFile string `yaml:"body_file,omitempty"`
SHA256 string `yaml:"sha256,omitempty"`
}
// CaptureRule maps a named source onto a variable name.
type CaptureRule struct {
From string `yaml:"from,omitempty"`
Path string `yaml:"path,omitempty"`
Name string `yaml:"name,omitempty"`
As string `yaml:"as"`
Identity string `yaml:"identity,omitempty"`
Category string `yaml:"category,omitempty"`
}
// NormalizeRule names a per-step normalizer override.
type NormalizeRule struct {
Path string `yaml:"path,omitempty"`
Disable bool `yaml:"disable,omitempty"`
}
// Body is verbatim request or response bytes stored as a YAML literal scalar.
type Body string
// RecordConfig injects the HTTP target, client and body bound for recording.
type RecordConfig struct {
Target string
Client *http.Client
MaxBody int64
Store *Store
Rules Rules
}
// ReplayConfig injects the HTTP target, client and body bound for replay.
type ReplayConfig struct {
Target string
Client *http.Client
MaxBody int64
Store *Store
BaseDir string
}
// Result is the outcome of replaying a flow.
type Result struct {
OK bool
Steps []StepResult
}
// StepResult is the outcome of one replayed step.
type StepResult struct {
ID string
OK bool
Skipped bool
Diffs []Diff
}
// Diff is one structural JSON or raw-byte mismatch.
type Diff struct {
Path string
Expected string
Actual string
Offset int
Byte bool
}
// MismatchError is returned when replay finds one or more differences.
type MismatchError struct {
Result Result
}
func (e *MismatchError) Error() string {
if e == nil {
return "tide: mismatch"
}
var b strings.Builder
for _, step := range e.Result.Steps {
for _, d := range step.Diffs {
if b.Len() > 0 {
b.WriteByte('\n')
}
if d.Byte {
fmt.Fprintf(&b, "step %s: body mismatch at byte %d: expected %s actual %s", step.ID, d.Offset, redactSecrets(d.Expected), redactSecrets(d.Actual))
continue
}
fmt.Fprintf(&b, "step %s: %s: expected %s actual %s", step.ID, d.Path, redactSecrets(d.Expected), redactSecrets(d.Actual))
}
}
if b.Len() == 0 {
return "tide: mismatch"
}
return b.String()
}
func validateFlow(flow Flow) error {
if flow.Version != CurrentVersion {
return fmt.Errorf("tide: unsupported version %d (want %d)", flow.Version, CurrentVersion)
}
if strings.TrimSpace(flow.Name) == "" {
return fmt.Errorf("tide: flow name is required")
}
if len(flow.Steps) == 0 {
return fmt.Errorf("tide: flow %q has no steps", flow.Name)
}
seen := make(map[string]struct{}, len(flow.Steps))
for i, step := range flow.Steps {
if strings.TrimSpace(step.ID) == "" {
return fmt.Errorf("tide: steps[%d] is missing id", i)
}
if _, dup := seen[step.ID]; dup {
return fmt.Errorf("tide: duplicate step id %q", step.ID)
}
seen[step.ID] = struct{}{}
if strings.TrimSpace(step.Request.Method) == "" {
return fmt.Errorf("tide: step %s is missing request method", step.ID)
}
if strings.TrimSpace(step.Request.Path) == "" {
return fmt.Errorf("tide: step %s is missing request path", step.ID)
}
if err := validateSidecar(step.Response.BodyFile); err != nil {
return fmt.Errorf("tide: step %s: %w", step.ID, err)
}
}
return nil
}
func materializeSidecar(base string, resp *Response) error {
if resp == nil || resp.BodyFile == "" {
return nil
}
if err := validateSidecar(resp.BodyFile); err != nil {
return err
}
path := resp.BodyFile
if base != "" {
path = filepath.Join(base, resp.BodyFile)
}
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("tide: read body_file %s: %w", resp.BodyFile, err)
}
sum := sha256.Sum256(raw)
got := hex.EncodeToString(sum[:])
if resp.SHA256 != "" && !strings.EqualFold(got, resp.SHA256) {
return fmt.Errorf("tide: body_file %s digest mismatch", resp.BodyFile)
}
resp.Body = Body(raw)
return nil
}
func validateSidecar(path string) error {
if path == "" {
return nil
}
if filepath.IsAbs(path) {
return fmt.Errorf("body_file %q must be a relative path", path)
}
clean := filepath.ToSlash(filepath.Clean(path))
if clean == ".." || strings.HasPrefix(clean, "../") {
return fmt.Errorf("body_file %q escapes the fixture directory", path)
}
return nil
}
func maxBody(n int64) int64 {
if n <= 0 {
return DefaultMaxBody
}
return n
}