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
This commit is contained in:
235
modules/tide/flow.go
Normal file
235
modules/tide/flow.go
Normal file
@@ -0,0 +1,235 @@
|
||||
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)
|
||||
}
|
||||
resolved, err := resolvePath(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tide: read body_file %s: %w", resp.BodyFile, err)
|
||||
}
|
||||
if base != "" {
|
||||
root, err := resolvePath(base)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tide: fixture dir: %w", err)
|
||||
}
|
||||
if resolved != root && !strings.HasPrefix(resolved, root+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("tide: body_file %q escapes the fixture directory", resp.BodyFile)
|
||||
}
|
||||
}
|
||||
raw, err := os.ReadFile(resolved)
|
||||
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)
|
||||
}
|
||||
if _, err := resolvePath(path); err != nil {
|
||||
return fmt.Errorf("body_file %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func maxBody(n int64) int64 {
|
||||
if n <= 0 {
|
||||
return DefaultMaxBody
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user