all repos — postern

@ 0c62cb9eb1848e5d4ab12ede27ed1b3b70f586f2

Modern mail management

clone
git clone https:///postern.git

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}