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