Files
summercms/tide/fixture.go
2026-09-17 14:41:18 +02:00

277 lines
6.8 KiB
Go

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 {
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))
}
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
}