internal/policy/http.go (view raw)
1package policy
2
3import (
4 "bytes"
5 "context"
6 "encoding/base64"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "strings"
13 "time"
14
15 "postern/internal/model"
16
17 "go.starlark.net/starlark"
18)
19
20const userAgent = "postern/v0.1.0-alpha"
21
22// HTTPModule is the main module struct for the HTTP client
23type HTTPModule struct{}
24
25func (m *HTTPModule) String() string { return "<module http>" }
26func (m *HTTPModule) Type() string { return "module" }
27func (m *HTTPModule) Freeze() {}
28func (m *HTTPModule) Truth() starlark.Bool { return true }
29func (m *HTTPModule) Hash() (uint32, error) {
30 return 0, errors.New("unhashable")
31}
32
33func (m *HTTPModule) Attr(name string) (starlark.Value, error) {
34 switch name {
35 case "get":
36 return starlark.NewBuiltin("get", m.newBuilder), nil
37 case "post":
38 return starlark.NewBuiltin("post", m.newBuilder), nil
39 case "put":
40 return starlark.NewBuiltin("put", m.newBuilder), nil
41 case "delete":
42 return starlark.NewBuiltin("delete", m.newBuilder), nil
43 case "patch":
44 return starlark.NewBuiltin("patch", m.newBuilder), nil
45 case "request":
46 return starlark.NewBuiltin("request", m.newRequest), nil
47 }
48 return nil, nil
49}
50
51func (m *HTTPModule) AttrNames() []string {
52 return []string{"get", "post", "put", "delete", "patch", "request"}
53}
54
55// newBuilder creates a new RequestBuilder for GET, POST, PUT, DELETE, PATCH
56func (m *HTTPModule) newBuilder(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
57 var url string
58 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &url); err != nil {
59 return nil, err
60 }
61
62 method := strings.ToUpper(b.Name())
63 return &RequestBuilder{
64 method: method,
65 url: url,
66 headers: http.Header{},
67 timeout: 30 * time.Second,
68 client: http.DefaultClient,
69 }, nil
70}
71
72// newRequest creates a new RequestBuilder for a custom HTTP method
73func (m *HTTPModule) newRequest(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
74 var method, url string
75 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &method, &url); err != nil {
76 return nil, err
77 }
78
79 return &RequestBuilder{
80 method: strings.ToUpper(method),
81 url: url,
82 headers: http.Header{},
83 timeout: 30 * time.Second,
84 client: http.DefaultClient,
85 }, nil
86}
87
88// RequestBuilder holds the request configuration before sending
89type RequestBuilder struct {
90 client *http.Client
91 method string
92 url string
93 headers http.Header
94 body []byte
95 timeout time.Duration
96}
97
98func (rb *RequestBuilder) String() string {
99 return fmt.Sprintf("<RequestBuilder %s %s>", rb.method, rb.url)
100}
101func (rb *RequestBuilder) Type() string { return "RequestBuilder" }
102func (rb *RequestBuilder) Freeze() {}
103func (rb *RequestBuilder) Truth() starlark.Bool {
104 // A request builder is truthy if it has a URL
105 return starlark.Bool(rb.url != "")
106}
107func (rb *RequestBuilder) Hash() (uint32, error) {
108 return 0, errors.New("unhashable")
109}
110
111func (rb *RequestBuilder) Attr(name string) (starlark.Value, error) {
112 switch name {
113 case "header":
114 return starlark.NewBuiltin("header", rb.rbHeader), nil
115 case "headers":
116 return starlark.NewBuiltin("headers", rb.rbHeaders), nil
117 case "bearer":
118 return starlark.NewBuiltin("bearer", rb.rbBearer), nil
119 case "basic_auth":
120 return starlark.NewBuiltin("basic_auth", rb.rbBasicAuth), nil
121 case "json":
122 return starlark.NewBuiltin("json", rb.rbJson), nil
123 case "body":
124 return starlark.NewBuiltin("body", rb.rbBody), nil
125 case "timeout":
126 return starlark.NewBuiltin("timeout", rb.rbTimeout), nil
127 case "send":
128 return starlark.NewBuiltin("send", rb.rbSend), nil
129 }
130 return nil, nil
131}
132
133func (rb *RequestBuilder) AttrNames() []string {
134 return []string{"header", "headers", "bearer", "basic_auth", "json", "body", "timeout", "send"}
135}
136
137// rbHeader sets a single header
138func (rb *RequestBuilder) rbHeader(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
139 var key, value string
140 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &key, &value); err != nil {
141 return nil, err
142 }
143 rb.headers.Set(key, value)
144 return rb, nil
145}
146
147// rbHeaders sets multiple headers from a dict
148func (rb *RequestBuilder) rbHeaders(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
149 var headersDict *starlark.Dict
150 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &headersDict); err != nil {
151 return nil, err
152 }
153
154 for _, item := range headersDict.Items() {
155 key, ok := item[0].(starlark.String)
156 if !ok {
157 return nil, fmt.Errorf("header key must be a string, got %s", item[0].Type())
158 }
159 value, ok := item[1].(starlark.String)
160 if !ok {
161 return nil, fmt.Errorf("header value must be a string, got %s", item[1].Type())
162 }
163 rb.headers.Set(string(key), string(value))
164 }
165 return rb, nil
166}
167
168// rbBearer sets the Authorization header with a Bearer token
169func (rb *RequestBuilder) rbBearer(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
170 var token string
171 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &token); err != nil {
172 return nil, err
173 }
174 rb.headers.Set("Authorization", "Bearer "+token)
175 return rb, nil
176}
177
178// rbBasicAuth sets the Authorization header with Basic auth
179func (rb *RequestBuilder) rbBasicAuth(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
180 var user, pass string
181 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &user, &pass); err != nil {
182 return nil, err
183 }
184 auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
185 rb.headers.Set("Authorization", "Basic "+auth)
186 return rb, nil
187}
188
189// rbJson sets the body as JSON and Content-Type header
190func (rb *RequestBuilder) rbJson(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
191 var data starlark.Value
192 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &data); err != nil {
193 return nil, err
194 }
195
196 goVal, err := starlarkToJSON(data)
197 if err != nil {
198 return nil, err
199 }
200
201 body, err := json.Marshal(goVal)
202 if err != nil {
203 return nil, err
204 }
205
206 rb.body = body
207 rb.headers.Set("Content-Type", "application/json")
208 return rb, nil
209}
210
211// rbBody sets the raw body
212func (rb *RequestBuilder) rbBody(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
213 var data starlark.Value
214 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &data); err != nil {
215 return nil, err
216 }
217
218 switch v := data.(type) {
219 case starlark.String:
220 rb.body = []byte(v)
221 case starlark.Bytes:
222 rb.body = []byte(v)
223 default:
224 return nil, fmt.Errorf("body must be a string or bytes, got %s", data.Type())
225 }
226 return rb, nil
227}
228
229// rbTimeout sets the request timeout
230func (rb *RequestBuilder) rbTimeout(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
231 var seconds starlark.Value
232 if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &seconds); err != nil {
233 return nil, err
234 }
235
236 switch v := seconds.(type) {
237 case starlark.Int:
238 if i, ok := v.Int64(); ok {
239 rb.timeout = time.Duration(i) * time.Second
240 } else {
241 return nil, fmt.Errorf("timeout must be a valid integer")
242 }
243 case starlark.Float:
244 f, _ := starlark.AsFloat(v)
245 rb.timeout = time.Duration(f) * time.Second
246 default:
247 return nil, fmt.Errorf("timeout must be a number, got %s", seconds.Type())
248 }
249 return rb, nil
250}
251
252// rbSend executes the HTTP request
253func (rb *RequestBuilder) rbSend(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (_ starlark.Value, err error) {
254 ctx, ok := thread.Local("context").(context.Context)
255 if !ok || ctx == nil {
256 ctx = context.Background()
257 }
258
259 ctx, cancel := context.WithTimeout(ctx, rb.timeout)
260 defer cancel()
261
262 req, err := http.NewRequestWithContext(ctx, rb.method, rb.url, bytes.NewReader(rb.body))
263 if err != nil {
264 return nil, fmt.Errorf("http request creation failed: %s", err)
265 }
266
267 // Set default User-Agent if not already set
268 if rb.headers.Get("User-Agent") == "" {
269 req.Header.Set("User-Agent", userAgent)
270 }
271
272 // Apply headers
273 for key, values := range rb.headers {
274 for _, value := range values {
275 req.Header.Add(key, value)
276 }
277 }
278
279 resp, err := rb.client.Do(req)
280 if err != nil {
281 return nil, fmt.Errorf("http request failed: %s", err)
282 }
283 defer model.Wrap(resp.Body.Close, &err, "rbSend closing response body")
284
285 body, err := io.ReadAll(resp.Body)
286 if err != nil {
287 return nil, fmt.Errorf("failed to read response body: %s", err)
288 }
289
290 return &Response{
291 statusCode: resp.StatusCode,
292 status: resp.Status,
293 headers: resp.Header,
294 bodyBytes: body,
295 url: rb.url,
296 }, nil
297}
298
299// Response represents an HTTP response
300type Response struct {
301 statusCode int
302 status string
303 headers http.Header
304 bodyBytes []byte
305 url string
306}
307
308func (r *Response) String() string { return fmt.Sprintf("<Response %d>", r.statusCode) }
309func (r *Response) Type() string { return "Response" }
310func (r *Response) Freeze() {}
311func (r *Response) Truth() starlark.Bool {
312 // A response is truthy if it's successful (2xx)
313 return r.statusCode >= 200 && r.statusCode < 300
314}
315func (r *Response) Hash() (uint32, error) {
316 return 0, errors.New("unhashable")
317}
318
319func (r *Response) Attr(name string) (starlark.Value, error) {
320 switch name {
321 case "status_code":
322 return starlark.MakeInt(r.statusCode), nil
323 case "status":
324 return starlark.String(r.status), nil
325 case "is_ok":
326 return starlark.Bool(r.statusCode >= 200 && r.statusCode < 300), nil
327 case "url":
328 return starlark.String(r.url), nil
329 case "headers":
330 // Build a dict with lowercase header names
331 dict := starlark.NewDict(len(r.headers))
332 for key, values := range r.headers {
333 err := dict.SetKey(starlark.String(key), starlark.String(strings.Join(values, ", ")))
334 if err != nil {
335 return nil, err
336 }
337 }
338 return dict, nil
339 case "json":
340 return starlark.NewBuiltin("json", r.rJson), nil
341 case "text":
342 return starlark.NewBuiltin("text", r.rText), nil
343 case "body":
344 return starlark.NewBuiltin("body", r.rBody), nil
345 }
346 return nil, nil
347}
348
349func (r *Response) AttrNames() []string {
350 return []string{"status_code", "status", "is_ok", "url", "headers", "json", "text", "body"}
351}
352
353// rJson parses the response body as JSON
354func (r *Response) rJson(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
355 var v any
356 if err := json.Unmarshal(r.bodyBytes, &v); err != nil {
357 return nil, fmt.Errorf("response body is not valid JSON: %s", err)
358 }
359
360 return jsonToStarlark(v)
361}
362
363// rText returns the response body as a string
364func (r *Response) rText(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
365 return starlark.String(r.bodyBytes), nil
366}
367
368// rBody returns the response body as bytes
369func (r *Response) rBody(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) {
370 return starlark.Bytes(r.bodyBytes), nil
371}
372
373// starlarkToJSON converts a Starlark value to a Go value suitable for JSON marshaling
374func starlarkToJSON(v starlark.Value) (any, error) {
375 switch val := v.(type) {
376 case starlark.String:
377 return string(val), nil
378 case starlark.Int:
379 if i, ok := val.Int64(); ok {
380 return i, nil
381 }
382
383 return nil, errors.New("starlark integer unrepresentable in go")
384 case starlark.Float:
385 if f, ok := starlark.AsFloat(val); ok {
386 return f, nil
387 }
388
389 return nil, errors.New("starlark float unrepresentable in go")
390 case starlark.Bool:
391 return bool(val), nil
392 case starlark.NoneType:
393 return nil, nil
394 case *starlark.Dict:
395 dict := make(map[string]any)
396 for _, item := range val.Items() {
397 key, ok := item[0].(starlark.String)
398 if !ok {
399 return nil, fmt.Errorf("dict key must be a string, got %s", item[0].Type())
400 }
401 value, err := starlarkToJSON(item[1])
402 if err != nil {
403 return nil, err
404 }
405 dict[string(key)] = value
406 }
407 return dict, nil
408 case *starlark.List:
409 list := make([]any, val.Len())
410 i := 0
411 for it := range val.Elements() {
412 item, err := starlarkToJSON(it)
413 if err != nil {
414 return nil, err
415 }
416 list[i] = item
417 i++
418 }
419 return list, nil
420 case starlark.Tuple:
421 list := make([]any, val.Len())
422 i := 0
423 for it := range val.Elements() {
424 item, err := starlarkToJSON(it)
425 if err != nil {
426 return nil, err
427 }
428 list[i] = item
429 i++
430 }
431 return list, nil
432 case starlark.Bytes:
433 return []byte(val), nil
434 default:
435 return nil, fmt.Errorf("unsupported type for JSON conversion: %s", val.Type())
436 }
437}
438
439// jsonToStarlark converts a Go value to a Starlark value
440func jsonToStarlark(v any) (starlark.Value, error) {
441 switch x := v.(type) {
442 case string:
443 return starlark.String(x), nil
444 case float64:
445 // Check if it's actually an integer
446 if x == float64(int64(x)) {
447 return starlark.MakeInt64(int64(x)), nil
448 }
449 return starlark.Float(x), nil
450 case bool:
451 return starlark.Bool(x), nil
452 case nil:
453 return starlark.None, nil
454 case map[string]any:
455 dict := starlark.NewDict(len(x))
456 for key, value := range x {
457 starlarkValue, err := jsonToStarlark(value)
458 if err != nil {
459 return nil, err
460 }
461 err = dict.SetKey(starlark.String(key), starlarkValue)
462 if err != nil {
463 return nil, err
464 }
465 }
466 return dict, nil
467 case []any:
468 list := make([]starlark.Value, len(x))
469 for i, value := range x {
470 starlarkValue, err := jsonToStarlark(value)
471 if err != nil {
472 return nil, err
473 }
474 list[i] = starlarkValue
475 }
476 return starlark.NewList(list), nil
477 default:
478 return nil, fmt.Errorf("unsupported type for JSON conversion: %T", v)
479 }
480}