Files
Bose-SoundTouch/pkg/service/proxy/recorder.go
T

224 lines
5.2 KiB
Go

package proxy
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// Recorder handles persisting HTTP interactions as .http files.
type Recorder struct {
BaseDir string
SessionID string
SessionDir string
Patterns PathPatterns
Redact bool
counter uint64
variables map[string]string
mu sync.Mutex
}
// NewRecorder creates a new HTTP interaction recorder.
func NewRecorder(baseDir string) *Recorder {
sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid())
return &Recorder{
BaseDir: baseDir,
SessionID: sessionID,
Patterns: DefaultPatterns(),
variables: make(map[string]string),
}
}
// Record persists a request and response to a .http file in the specified category (e.g., "self" or "upstream").
func (r *Recorder) Record(category string, req *http.Request, res *http.Response) error {
if r.BaseDir == "" {
return nil
}
sanitizedSegments, replacements := r.getSanitizedSegments(req.URL.Path)
dir := r.getRecordingDir(category, sanitizedSegments)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
path := r.getRecordingPath(dir, req.Method)
var buf bytes.Buffer
r.writeRequest(&buf, req, replacements)
if res != nil {
r.writeResponse(&buf, res)
}
if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil {
return err
}
return r.updateEnvFile(replacements)
}
func (r *Recorder) getSanitizedSegments(path string) ([]string, map[string]string) {
pathSegments := strings.Split(strings.Trim(path, "/"), "/")
sanitizedSegments := make([]string, 0, len(pathSegments))
replacements := make(map[string]string)
for _, segment := range pathSegments {
if segment == "" {
continue
}
sanitized, replacement := r.Patterns.Sanitize(segment)
sanitizedSegments = append(sanitizedSegments, sanitized)
if replacement != "" {
replacements[segment] = replacement
}
}
return sanitizedSegments, replacements
}
func (r *Recorder) getRecordingDir(category string, sanitizedSegments []string) string {
subDir := "root"
if len(sanitizedSegments) > 0 {
subDir = filepath.Join(sanitizedSegments...)
}
return filepath.Join(r.BaseDir, "interactions", r.SessionID, category, subDir)
}
func (r *Recorder) getRecordingPath(dir, method string) string {
timestamp := time.Now().Format("15-04-05.000")
count := atomic.AddUint64(&r.counter, 1)
filename := fmt.Sprintf("%04d-%s-%s.http", count, timestamp, method)
return filepath.Join(dir, filename)
}
func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacements map[string]string) {
displayURL := req.URL.String()
for orig, repl := range replacements {
displayURL = strings.ReplaceAll(displayURL, orig, "{{"+strings.Trim(repl, "{}")+"}}")
}
fmt.Fprintf(buf, "### %s %s\n", req.Method, displayURL)
for orig, repl := range replacements {
key := strings.Trim(repl, "{}")
fmt.Fprintf(buf, "// %s: %s\n", key, orig)
}
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
for k, vv := range req.Header {
if r.Redact && isSensitive(k) {
fmt.Fprintf(buf, "%s: [REDACTED]\n", k)
continue
}
for _, v := range vv {
val := v
for orig, repl := range replacements {
val = strings.ReplaceAll(val, orig, "{{"+strings.Trim(repl, "{}")+"}}")
}
fmt.Fprintf(buf, "%s: %s\n", k, val)
}
}
buf.WriteString("\n")
if req.Body != nil {
bodyBytes, err := io.ReadAll(req.Body)
if err == nil {
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
buf.Write(bodyBytes)
buf.WriteString("\n")
}
}
}
func (r *Recorder) writeResponse(buf *bytes.Buffer, res *http.Response) {
buf.WriteString("\n")
buf.WriteString("> {% \n")
fmt.Fprintf(buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
buf.WriteString(" // Headers:\n")
for k, vv := range res.Header {
if r.Redact && isSensitive(k) {
fmt.Fprintf(buf, " // %s: [REDACTED]\n", k)
continue
}
for _, v := range vv {
fmt.Fprintf(buf, " // %s: %s\n", k, v)
}
}
buf.WriteString("%}\n")
if res.Body != nil {
bodyBytes, err := io.ReadAll(res.Body)
if err == nil {
res.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
contentType := res.Header.Get("Content-Type")
if strings.Contains(contentType, "xml") || strings.Contains(contentType, "json") || strings.Contains(contentType, "text") {
buf.WriteString("\n/*\n")
buf.Write(bodyBytes)
buf.WriteString("\n*/\n")
} else {
fmt.Fprintf(buf, "\n// [Binary response body: %d bytes]\n", len(bodyBytes))
}
}
}
}
func (r *Recorder) updateEnvFile(newVars map[string]string) error {
if len(newVars) == 0 {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
changed := false
for orig, repl := range newVars {
key := strings.Trim(repl, "{}")
if r.variables[key] != orig {
r.variables[key] = orig
changed = true
}
}
if !changed {
return nil
}
envFile := filepath.Join(r.BaseDir, "interactions", r.SessionID, "http-client.env.json")
// Create the structure: {"session": {"key": "val"}}
content := map[string]map[string]string{
"session": r.variables,
}
data, err := json.MarshalIndent(content, "", " ")
if err != nil {
return err
}
return os.WriteFile(envFile, data, 0644)
}