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. Request // part files are checked against their sha256, relative to path's directory. func LoadFlow(path string) (Flow, error) { raw, err := os.ReadFile(path) if err != nil { return Flow{}, fmt.Errorf("tide: read %s: %w", path, err) } flow, err := ParseFlow(raw) if err != nil { return Flow{}, err } if err := verifyPartFiles(filepath.Dir(path), flow); err != nil { return Flow{}, err } return flow, nil } // 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 { return saveFlow(path, flow, false) } // SaveFlowExclusive writes a validated flow and fails if path already exists. func SaveFlowExclusive(path string, flow Flow) error { return saveFlow(path, flow, true) } func saveFlow(path string, flow Flow, exclusive bool) 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) } } if exclusive { f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) if err != nil { return fmt.Errorf("tide: create fixture: %w", err) } ok := false defer func() { _ = f.Close() if !ok { _ = os.Remove(path) } }() if _, err := f.Write(raw); err != nil { return fmt.Errorf("tide: write fixture: %w", err) } if err := f.Sync(); err != nil { return fmt.Errorf("tide: sync fixture: %w", err) } if err := f.Close(); err != nil { return fmt.Errorf("tide: close fixture: %w", err) } ok = true return nil } 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)) writeParts(b, indent, req.Parts) } func writeParts(b *strings.Builder, indent int, parts []Part) { if len(parts) == 0 { return } pad := strings.Repeat(" ", indent) fmt.Fprintf(b, "%sparts:\n", pad) inner := strings.Repeat(" ", indent+2) for _, p := range parts { fmt.Fprintf(b, "%s- name: %s\n", inner, encodeScalar(p.Name)) for _, kv := range [][2]string{ {"value", p.Value}, {"file", p.File}, {"filename", p.Filename}, {"content_type", p.ContentType}, {"sha256", p.SHA256}, } { if kv[1] != "" { fmt.Fprintf(b, "%s %s: %s\n", inner, kv[0], encodeScalar(kv[1])) } } } } 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- as: %s\n", inner, encodeScalar(rule.As)) if rule.From != "" { fmt.Fprintf(b, "%s from: %s\n", inner, encodeScalar(rule.From)) } if rule.Path != "" { fmt.Fprintf(b, "%s path: %s\n", inner, encodeScalar(rule.Path)) } if rule.Name != "" { fmt.Fprintf(b, "%s name: %s\n", inner, encodeScalar(rule.Name)) } if rule.Identity != "" { fmt.Fprintf(b, "%s identity: %s\n", inner, encodeScalar(rule.Identity)) } if rule.Category != "" { fmt.Fprintf(b, "%s category: %s\n", inner, encodeScalar(rule.Category)) } } } 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 }