- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
277 lines
6.8 KiB
Go
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
|
|
}
|