220 lines
5.7 KiB
Go
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
|
|
}
|