all repos — postern

@ 50dc541db37adabbbb220f4abf62aba899c48f5d

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	"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}