package policy import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "postern/internal/model" "go.starlark.net/starlark" ) const userAgent = "postern/v0.1.0-alpha" // HTTPModule is the main module struct for the HTTP client type HTTPModule struct{} func (m *HTTPModule) String() string { return "" } func (m *HTTPModule) Type() string { return "module" } func (m *HTTPModule) Freeze() {} func (m *HTTPModule) Truth() starlark.Bool { return true } func (m *HTTPModule) Hash() (uint32, error) { return 0, errors.New("unhashable") } func (m *HTTPModule) Attr(name string) (starlark.Value, error) { switch name { case "get": return starlark.NewBuiltin("get", m.newBuilder), nil case "post": return starlark.NewBuiltin("post", m.newBuilder), nil case "put": return starlark.NewBuiltin("put", m.newBuilder), nil case "delete": return starlark.NewBuiltin("delete", m.newBuilder), nil case "patch": return starlark.NewBuiltin("patch", m.newBuilder), nil case "request": return starlark.NewBuiltin("request", m.newRequest), nil } return nil, nil } func (m *HTTPModule) AttrNames() []string { return []string{"get", "post", "put", "delete", "patch", "request"} } // newBuilder creates a new RequestBuilder for GET, POST, PUT, DELETE, PATCH func (m *HTTPModule) newBuilder(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var url string if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &url); err != nil { return nil, err } method := strings.ToUpper(b.Name()) return &RequestBuilder{ method: method, url: url, headers: http.Header{}, timeout: 30 * time.Second, client: http.DefaultClient, }, nil } // newRequest creates a new RequestBuilder for a custom HTTP method func (m *HTTPModule) newRequest(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var method, url string if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &method, &url); err != nil { return nil, err } return &RequestBuilder{ method: strings.ToUpper(method), url: url, headers: http.Header{}, timeout: 30 * time.Second, client: http.DefaultClient, }, nil } // RequestBuilder holds the request configuration before sending type RequestBuilder struct { client *http.Client method string url string headers http.Header body []byte timeout time.Duration } func (rb *RequestBuilder) String() string { return fmt.Sprintf("", rb.method, rb.url) } func (rb *RequestBuilder) Type() string { return "RequestBuilder" } func (rb *RequestBuilder) Freeze() {} func (rb *RequestBuilder) Truth() starlark.Bool { // A request builder is truthy if it has a URL return starlark.Bool(rb.url != "") } func (rb *RequestBuilder) Hash() (uint32, error) { return 0, errors.New("unhashable") } func (rb *RequestBuilder) Attr(name string) (starlark.Value, error) { switch name { case "header": return starlark.NewBuiltin("header", rb.rbHeader), nil case "headers": return starlark.NewBuiltin("headers", rb.rbHeaders), nil case "bearer": return starlark.NewBuiltin("bearer", rb.rbBearer), nil case "basic_auth": return starlark.NewBuiltin("basic_auth", rb.rbBasicAuth), nil case "json": return starlark.NewBuiltin("json", rb.rbJson), nil case "body": return starlark.NewBuiltin("body", rb.rbBody), nil case "timeout": return starlark.NewBuiltin("timeout", rb.rbTimeout), nil case "send": return starlark.NewBuiltin("send", rb.rbSend), nil } return nil, nil } func (rb *RequestBuilder) AttrNames() []string { return []string{"header", "headers", "bearer", "basic_auth", "json", "body", "timeout", "send"} } // rbHeader sets a single header func (rb *RequestBuilder) rbHeader(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var key, value string if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &key, &value); err != nil { return nil, err } rb.headers.Set(key, value) return rb, nil } // rbHeaders sets multiple headers from a dict func (rb *RequestBuilder) rbHeaders(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var headersDict *starlark.Dict if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &headersDict); err != nil { return nil, err } for _, item := range headersDict.Items() { key, ok := item[0].(starlark.String) if !ok { return nil, fmt.Errorf("header key must be a string, got %s", item[0].Type()) } value, ok := item[1].(starlark.String) if !ok { return nil, fmt.Errorf("header value must be a string, got %s", item[1].Type()) } rb.headers.Set(string(key), string(value)) } return rb, nil } // rbBearer sets the Authorization header with a Bearer token func (rb *RequestBuilder) rbBearer(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var token string if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &token); err != nil { return nil, err } rb.headers.Set("Authorization", "Bearer "+token) return rb, nil } // rbBasicAuth sets the Authorization header with Basic auth func (rb *RequestBuilder) rbBasicAuth(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var user, pass string if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &user, &pass); err != nil { return nil, err } auth := base64.StdEncoding.EncodeToString([]byte(user + ":" + pass)) rb.headers.Set("Authorization", "Basic "+auth) return rb, nil } // rbJson sets the body as JSON and Content-Type header func (rb *RequestBuilder) rbJson(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var data starlark.Value if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &data); err != nil { return nil, err } goVal, err := starlarkToJSON(data) if err != nil { return nil, err } body, err := json.Marshal(goVal) if err != nil { return nil, err } rb.body = body rb.headers.Set("Content-Type", "application/json") return rb, nil } // rbBody sets the raw body func (rb *RequestBuilder) rbBody(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var data starlark.Value if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &data); err != nil { return nil, err } switch v := data.(type) { case starlark.String: rb.body = []byte(v) case starlark.Bytes: rb.body = []byte(v) default: return nil, fmt.Errorf("body must be a string or bytes, got %s", data.Type()) } return rb, nil } // rbTimeout sets the request timeout func (rb *RequestBuilder) rbTimeout(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var seconds starlark.Value if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 1, &seconds); err != nil { return nil, err } switch v := seconds.(type) { case starlark.Int: if i, ok := v.Int64(); ok { rb.timeout = time.Duration(i) * time.Second } else { return nil, fmt.Errorf("timeout must be a valid integer") } case starlark.Float: f, _ := starlark.AsFloat(v) rb.timeout = time.Duration(f) * time.Second default: return nil, fmt.Errorf("timeout must be a number, got %s", seconds.Type()) } return rb, nil } // rbSend executes the HTTP request func (rb *RequestBuilder) rbSend(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (_ starlark.Value, err error) { ctx, ok := thread.Local("context").(context.Context) if !ok || ctx == nil { ctx = context.Background() } ctx, cancel := context.WithTimeout(ctx, rb.timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, rb.method, rb.url, bytes.NewReader(rb.body)) if err != nil { return nil, fmt.Errorf("http request creation failed: %s", err) } // Set default User-Agent if not already set if rb.headers.Get("User-Agent") == "" { req.Header.Set("User-Agent", userAgent) } // Apply headers for key, values := range rb.headers { for _, value := range values { req.Header.Add(key, value) } } resp, err := rb.client.Do(req) if err != nil { return nil, fmt.Errorf("http request failed: %s", err) } defer model.Wrap(resp.Body.Close, &err, "rbSend closing response body") body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %s", err) } return &Response{ statusCode: resp.StatusCode, status: resp.Status, headers: resp.Header, bodyBytes: body, url: rb.url, }, nil } // Response represents an HTTP response type Response struct { statusCode int status string headers http.Header bodyBytes []byte url string } func (r *Response) String() string { return fmt.Sprintf("", r.statusCode) } func (r *Response) Type() string { return "Response" } func (r *Response) Freeze() {} func (r *Response) Truth() starlark.Bool { // A response is truthy if it's successful (2xx) return r.statusCode >= 200 && r.statusCode < 300 } func (r *Response) Hash() (uint32, error) { return 0, errors.New("unhashable") } func (r *Response) Attr(name string) (starlark.Value, error) { switch name { case "status_code": return starlark.MakeInt(r.statusCode), nil case "status": return starlark.String(r.status), nil case "is_ok": return starlark.Bool(r.statusCode >= 200 && r.statusCode < 300), nil case "url": return starlark.String(r.url), nil case "headers": // Build a dict with lowercase header names dict := starlark.NewDict(len(r.headers)) for key, values := range r.headers { err := dict.SetKey(starlark.String(key), starlark.String(strings.Join(values, ", "))) if err != nil { return nil, err } } return dict, nil case "json": return starlark.NewBuiltin("json", r.rJson), nil case "text": return starlark.NewBuiltin("text", r.rText), nil case "body": return starlark.NewBuiltin("body", r.rBody), nil } return nil, nil } func (r *Response) AttrNames() []string { return []string{"status_code", "status", "is_ok", "url", "headers", "json", "text", "body"} } // rJson parses the response body as JSON func (r *Response) rJson(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var v any if err := json.Unmarshal(r.bodyBytes, &v); err != nil { return nil, fmt.Errorf("response body is not valid JSON: %s", err) } return jsonToStarlark(v) } // rText returns the response body as a string func (r *Response) rText(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { return starlark.String(r.bodyBytes), nil } // rBody returns the response body as bytes func (r *Response) rBody(thread *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { return starlark.Bytes(r.bodyBytes), nil } // starlarkToJSON converts a Starlark value to a Go value suitable for JSON marshaling func starlarkToJSON(v starlark.Value) (any, error) { switch val := v.(type) { case starlark.String: return string(val), nil case starlark.Int: if i, ok := val.Int64(); ok { return i, nil } return nil, errors.New("starlark integer unrepresentable in go") case starlark.Float: if f, ok := starlark.AsFloat(val); ok { return f, nil } return nil, errors.New("starlark float unrepresentable in go") case starlark.Bool: return bool(val), nil case starlark.NoneType: return nil, nil case *starlark.Dict: dict := make(map[string]any) for _, item := range val.Items() { key, ok := item[0].(starlark.String) if !ok { return nil, fmt.Errorf("dict key must be a string, got %s", item[0].Type()) } value, err := starlarkToJSON(item[1]) if err != nil { return nil, err } dict[string(key)] = value } return dict, nil case *starlark.List: list := make([]any, val.Len()) i := 0 for it := range val.Elements() { item, err := starlarkToJSON(it) if err != nil { return nil, err } list[i] = item i++ } return list, nil case starlark.Tuple: list := make([]any, val.Len()) i := 0 for it := range val.Elements() { item, err := starlarkToJSON(it) if err != nil { return nil, err } list[i] = item i++ } return list, nil case starlark.Bytes: return []byte(val), nil default: return nil, fmt.Errorf("unsupported type for JSON conversion: %s", val.Type()) } } // jsonToStarlark converts a Go value to a Starlark value func jsonToStarlark(v any) (starlark.Value, error) { switch x := v.(type) { case string: return starlark.String(x), nil case float64: // Check if it's actually an integer if x == float64(int64(x)) { return starlark.MakeInt64(int64(x)), nil } return starlark.Float(x), nil case bool: return starlark.Bool(x), nil case nil: return starlark.None, nil case map[string]any: dict := starlark.NewDict(len(x)) for key, value := range x { starlarkValue, err := jsonToStarlark(value) if err != nil { return nil, err } err = dict.SetKey(starlark.String(key), starlarkValue) if err != nil { return nil, err } } return dict, nil case []any: list := make([]starlark.Value, len(x)) for i, value := range x { starlarkValue, err := jsonToStarlark(value) if err != nil { return nil, err } list[i] = starlarkValue } return starlark.NewList(list), nil default: return nil, fmt.Errorf("unsupported type for JSON conversion: %T", v) } }