Repository navigation
Expand file tree
/
Copy pathsample.go
More file actions
164 lines (146 loc) · 4.72 KB
/
Copy pathsample.go
File metadata and controls
164 lines (146 loc) · 4.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"loadcell/tmpl"
)
// sampleCache memoizes parsed templates for SendSample so {{seq}}
// accumulates across successive "Send" clicks of the same request.
// Without this each click would re-Parse and reset the counter to 0.
//
// Cache key is (request id, field), so editing a field re-parses but
// inherits the previous counter — the user sees seq keep climbing
// even after they tweak the URL or body. Different requests (by ID)
// have independent counters; unsaved requests share the empty-ID slot.
type sampleCache struct {
mu sync.Mutex
m map[string]*sampleEntry
}
type sampleEntry struct {
content string
tmpl *tmpl.Template
}
func newSampleCache() *sampleCache {
return &sampleCache{m: map[string]*sampleEntry{}}
}
func (c *sampleCache) get(id, field, content string) (*tmpl.Template, error) {
key := id + "\x00" + field
c.mu.Lock()
defer c.mu.Unlock()
if e, ok := c.m[key]; ok && e.content == content {
return e.tmpl, nil
}
t, err := tmpl.Parse(content)
if err != nil {
return nil, err
}
if prev, ok := c.m[key]; ok {
t.SetSeq(prev.tmpl.SeqValue())
}
c.m[key] = &sampleEntry{content: content, tmpl: t}
return t, nil
}
// SampleResponse is what the request-builder shows for a single "Send" hit
// against the configured target. Body is capped so a huge response can't
// crash the WebView; BodyTruncated lets the UI flag that.
type SampleResponse struct {
Status int `json:"status"`
StatusText string `json:"statusText"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
BodyBytes int `json:"bodyBytes"`
BodyTruncated bool `json:"bodyTruncated"`
ContentType string `json:"contentType"`
ElapsedMs float64 `json:"elapsedMs"`
Error string `json:"error"`
}
const sampleBodyCap = 1 << 20 // 1 MiB rendered max
// SendSample fires one request defined by req and returns the response. A
// network failure surfaces as a non-nil Error string with Status=0 so the
// UI can render the failure inline rather than throwing.
//
// Templated placeholders ({{uuid}}, {{seq}}, {{nowMs}}, {{randInt:a:b}}) in
// the URL, body, and header values are rendered fresh on every Send — same
// rules as the load engine, so the preview matches what workers will fire.
// A bad token returns a non-nil Error so the UI can surface the message
// inline instead of throwing.
func (a *App) SendSample(req SavedRequest) (SampleResponse, error) {
if strings.TrimSpace(req.URL) == "" {
return SampleResponse{}, errors.New("url is required")
}
method := req.Method
if method == "" {
method = http.MethodGet
}
urlTmpl, err := a.sample.get(req.ID, "url", req.URL)
if err != nil {
return SampleResponse{Error: fmt.Sprintf("url: %v", err)}, nil
}
bodyTmpl, err := a.sample.get(req.ID, "body", req.Body)
if err != nil {
return SampleResponse{Error: fmt.Sprintf("body: %v", err)}, nil
}
renderedBody := bodyTmpl.Render()
var body io.Reader
if renderedBody != "" {
body = strings.NewReader(renderedBody)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
httpReq, err := http.NewRequestWithContext(ctx, method, urlTmpl.Render(), body)
if err != nil {
return SampleResponse{Error: err.Error()}, nil
}
for _, h := range req.Headers {
if h.Key == "" {
continue
}
valTmpl, err := a.sample.get(req.ID, "h:"+h.Key, h.Value)
if err != nil {
return SampleResponse{Error: fmt.Sprintf("header %q: %v", h.Key, err)}, nil
}
httpReq.Header.Set(h.Key, valTmpl.Render())
}
client := &http.Client{Timeout: 30 * time.Second}
start := time.Now()
resp, err := client.Do(httpReq)
elapsed := time.Since(start)
if err != nil {
return SampleResponse{
ElapsedMs: float64(elapsed.Microseconds()) / 1000,
Error: err.Error(),
}, nil
}
defer resp.Body.Close()
// Read with a hard cap so the WebView doesn't try to render gigabytes.
limited := io.LimitReader(resp.Body, sampleBodyCap+1)
raw, _ := io.ReadAll(limited)
truncated := len(raw) > sampleBodyCap
if truncated {
raw = raw[:sampleBodyCap]
}
out := SampleResponse{
Status: resp.StatusCode,
StatusText: resp.Status, // includes the numeric prefix; UI can split
Headers: flattenHeaders(resp.Header),
Body: string(raw),
BodyBytes: len(raw),
BodyTruncated: truncated,
ContentType: resp.Header.Get("Content-Type"),
ElapsedMs: float64(elapsed.Microseconds()) / 1000,
}
return out, nil
}
func flattenHeaders(h http.Header) map[string]string {
out := make(map[string]string, len(h))
for k, v := range h {
out[k] = strings.Join(v, ", ")
}
return out
}