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:
229
tide/fixture.go
Normal file
229
tide/fixture.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package tide
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/token"
|
||||
)
|
||||
|
||||
func (b *Body) UnmarshalYAML(data []byte) error {
|
||||
if b == nil {
|
||||
return fmt.Errorf("tide: nil body")
|
||||
}
|
||||
var s string
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
*b = Body(s)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFlow reads and validates a version-1 YAML flow from path.
|
||||
func LoadFlow(path string) (Flow, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Flow{}, fmt.Errorf("tide: read %s: %w", path, err)
|
||||
}
|
||||
return ParseFlow(raw)
|
||||
}
|
||||
|
||||
// ParseFlow decodes a version-1 YAML flow, rejecting unknown fields.
|
||||
func ParseFlow(raw []byte) (Flow, error) {
|
||||
var flow Flow
|
||||
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||
if err := dec.Decode(&flow); err != nil {
|
||||
return Flow{}, fmt.Errorf("tide: parse flow: %w", err)
|
||||
}
|
||||
if err := validateFlow(flow); err != nil {
|
||||
return Flow{}, err
|
||||
}
|
||||
return flow, nil
|
||||
}
|
||||
|
||||
// SaveFlow writes a validated flow atomically, syncing before rename.
|
||||
func SaveFlow(path string, flow Flow) error {
|
||||
if err := validateFlow(flow); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := marshalFlow(flow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("tide: create fixture dir: %w", err)
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".tide-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("tide: create temp fixture: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err := tmp.Write(raw); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("tide: write fixture: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("tide: sync fixture: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("tide: close fixture: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("tide: commit fixture: %w", err)
|
||||
}
|
||||
ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func marshalFlow(flow Flow) ([]byte, error) {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "version: %d\n", flow.Version)
|
||||
writeKV(&b, 0, "name", flow.Name)
|
||||
if flow.Description != "" {
|
||||
writeKV(&b, 0, "description", flow.Description)
|
||||
}
|
||||
if flow.SeedHook != "" {
|
||||
writeKV(&b, 0, "seed_hook", flow.SeedHook)
|
||||
}
|
||||
b.WriteString("steps:\n")
|
||||
for _, step := range flow.Steps {
|
||||
writeStep(&b, step)
|
||||
}
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
func writeStep(b *strings.Builder, step Step) {
|
||||
fmt.Fprintf(b, " - id: %s\n", encodeScalar(step.ID))
|
||||
if step.RouteID != "" {
|
||||
writeKV(b, 4, "route_id", step.RouteID)
|
||||
}
|
||||
b.WriteString(" request:\n")
|
||||
writeRequest(b, 6, step.Request)
|
||||
b.WriteString(" response:\n")
|
||||
writeResponse(b, 6, step.Response)
|
||||
writeCapture(b, 4, step.Capture)
|
||||
writeNormalize(b, 4, step.Normalize)
|
||||
writeHeaders(b, 4, step.Headers)
|
||||
}
|
||||
|
||||
func writeRequest(b *strings.Builder, indent int, req Request) {
|
||||
writeKV(b, indent, "method", req.Method)
|
||||
writeKV(b, indent, "path", req.Path)
|
||||
if req.Query != "" {
|
||||
writeKV(b, indent, "query", req.Query)
|
||||
}
|
||||
writeHeaders(b, indent, req.Headers)
|
||||
writeBody(b, indent, string(req.Body))
|
||||
}
|
||||
|
||||
func writeResponse(b *strings.Builder, indent int, resp Response) {
|
||||
if resp.Status != 0 {
|
||||
fmt.Fprintf(b, "%sstatus: %d\n", strings.Repeat(" ", indent), resp.Status)
|
||||
}
|
||||
writeHeaders(b, indent, resp.Headers)
|
||||
writeBody(b, indent, string(resp.Body))
|
||||
if resp.BodyFile != "" {
|
||||
writeKV(b, indent, "body_file", resp.BodyFile)
|
||||
}
|
||||
if resp.SHA256 != "" {
|
||||
writeKV(b, indent, "sha256", resp.SHA256)
|
||||
}
|
||||
}
|
||||
|
||||
func writeCapture(b *strings.Builder, indent int, rules []CaptureRule) {
|
||||
if len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
pad := strings.Repeat(" ", indent)
|
||||
fmt.Fprintf(b, "%scapture:\n", pad)
|
||||
inner := strings.Repeat(" ", indent+2)
|
||||
for _, rule := range rules {
|
||||
fmt.Fprintf(b, "%s- path: %s\n", inner, encodeScalar(rule.Path))
|
||||
fmt.Fprintf(b, "%s as: %s\n", inner, encodeScalar(rule.As))
|
||||
}
|
||||
}
|
||||
|
||||
func writeNormalize(b *strings.Builder, indent int, rules []NormalizeRule) {
|
||||
if len(rules) == 0 {
|
||||
return
|
||||
}
|
||||
pad := strings.Repeat(" ", indent)
|
||||
fmt.Fprintf(b, "%snormalize:\n", pad)
|
||||
inner := strings.Repeat(" ", indent+2)
|
||||
for _, rule := range rules {
|
||||
fmt.Fprintf(b, "%s- path: %s\n", inner, encodeScalar(rule.Path))
|
||||
if rule.Disable {
|
||||
fmt.Fprintf(b, "%s disable: true\n", inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeHeaders(b *strings.Builder, indent int, headers map[string]string) {
|
||||
if len(headers) == 0 {
|
||||
return
|
||||
}
|
||||
pad := strings.Repeat(" ", indent)
|
||||
fmt.Fprintf(b, "%sheaders:\n", pad)
|
||||
keys := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
inner := strings.Repeat(" ", indent+2)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(b, "%s%s: %s\n", inner, k, encodeScalar(headers[k]))
|
||||
}
|
||||
}
|
||||
|
||||
func writeBody(b *strings.Builder, indent int, body string) {
|
||||
if body == "" {
|
||||
return
|
||||
}
|
||||
pad := strings.Repeat(" ", indent)
|
||||
header := token.LiteralBlockHeader(body)
|
||||
if header == "" {
|
||||
header = "|-"
|
||||
}
|
||||
fmt.Fprintf(b, "%sbody: %s\n", pad, header)
|
||||
inner := strings.Repeat(" ", indent+2)
|
||||
content := body
|
||||
if strings.HasSuffix(content, "\n") {
|
||||
content = content[:len(content)-1]
|
||||
}
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
b.WriteString(inner)
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
func writeKV(b *strings.Builder, indent int, key, value string) {
|
||||
pad := strings.Repeat(" ", indent)
|
||||
fmt.Fprintf(b, "%s%s: %s\n", pad, key, encodeScalar(value))
|
||||
}
|
||||
|
||||
func encodeScalar(v string) string {
|
||||
if v == "" {
|
||||
return `""`
|
||||
}
|
||||
if token.IsNeedQuoted(v) || strings.ContainsAny(v, " \t") {
|
||||
return strconv.Quote(v)
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user