feat(02-01): record and replay one route through the summer CLI
- Add a generic tide flow schema with YAML fixture IO and HTTP record/replay - Register parity:record and parity:replay on the bonfire summer tool - Diff JSON scalars at $.path and non-JSON bodies at the changed byte offset Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
183
tide/flow.go
Normal file
183
tide/flow.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package tide
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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 JSON path in the response onto a variable name.
|
||||
type CaptureRule struct {
|
||||
Path string `yaml:"path"`
|
||||
As string `yaml:"as"`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ReplayConfig injects the HTTP target, client and body bound for replay.
|
||||
type ReplayConfig struct {
|
||||
Target string
|
||||
Client *http.Client
|
||||
MaxBody int64
|
||||
}
|
||||
|
||||
// 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
|
||||
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, d.Expected, d.Actual)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "step %s: %s: expected %s actual %s", step.ID, d.Path, d.Expected, 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 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
|
||||
}
|
||||
Reference in New Issue
Block a user