From 1b54fb399934778a9e740c907863010aac09d6a4 Mon Sep 17 00:00:00 2001 From: Janko Date: Fri, 13 Mar 2026 15:31:25 +0100 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20implement=20HTTP=20hostcall=20?= =?UTF-8?q?=E2=80=94=20agents=20can=20call=20external=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add http_request hostcall to the capability membrane so agents can make HTTP requests to allowed external services. This is the single feature that makes agents actually useful (Product Phase 2, Task P2-1). Host-side (internal/hostcall/http.go): - ABI: http_request(method, url, headers, body, resp_buf) -> i32 - Security: allowed_hosts enforcement from manifest options - Timeout (10s default), max response size (1MB default) - Two-call pattern: returns -5 with size hint when buffer too small - Eventlog recording for deterministic replay (CM-4) SDK WASM wrapper (sdk/igor/hostcalls_http_wasm.go): - HTTPRequest, HTTPGet, HTTPPost convenience functions - Auto-retry with larger buffer on -5 (response too large) SDK native stubs (sdk/igor/hostcalls_http_stub.go): - Dispatches to MockBackend for native testing Mock support (sdk/igor/mock/mock.go): - HTTPHandler type + SetHTTPHandler for test configuration Co-Authored-By: Claude Opus 4.6 --- internal/eventlog/eventlog.go | 1 + internal/hostcall/http.go | 285 ++++++++++++++++++++++++++++++++ internal/hostcall/http_test.go | 193 +++++++++++++++++++++ internal/hostcall/registry.go | 13 ++ pkg/manifest/parse.go | 2 +- sdk/igor/hostcalls_http_stub.go | 27 +++ sdk/igor/hostcalls_http_wasm.go | 112 +++++++++++++ sdk/igor/mock/mock.go | 36 +++- sdk/igor/mock_backend.go | 1 + 9 files changed, 662 insertions(+), 8 deletions(-) create mode 100644 internal/hostcall/http.go create mode 100644 internal/hostcall/http_test.go create mode 100644 sdk/igor/hostcalls_http_stub.go create mode 100644 sdk/igor/hostcalls_http_wasm.go diff --git a/internal/eventlog/eventlog.go b/internal/eventlog/eventlog.go index 2985048..f1ff9dd 100644 --- a/internal/eventlog/eventlog.go +++ b/internal/eventlog/eventlog.go @@ -18,6 +18,7 @@ const ( WalletReceiptCount HostcallID = 5 WalletReceipt HostcallID = 6 NodePrice HostcallID = 7 + HTTPRequest HostcallID = 8 ) // Entry is a single observation recorded during a tick. diff --git a/internal/hostcall/http.go b/internal/hostcall/http.go new file mode 100644 index 0000000..bcc8b7e --- /dev/null +++ b/internal/hostcall/http.go @@ -0,0 +1,285 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hostcall + +import ( + "bytes" + "context" + "encoding/binary" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/simonovic86/igor/internal/eventlog" + "github.com/simonovic86/igor/pkg/manifest" + "github.com/tetratelabs/wazero" + "github.com/tetratelabs/wazero/api" +) + +const ( + // Default limits for HTTP hostcall. + defaultHTTPTimeoutMs = 10_000 + defaultMaxResponseBytes = 1 << 20 // 1 MB + maxURLBytes = 8192 + maxMethodBytes = 16 + maxHeadersBytes = 32768 + maxRequestBodyBytes = 1 << 20 // 1 MB + + // HTTP hostcall error codes (negative i32). + httpErrNetwork int32 = -1 + httpErrInputTooLong int32 = -2 + httpErrHostBlocked int32 = -3 + httpErrTimeout int32 = -4 + httpErrRespTooLarge int32 = -5 +) + +// HTTPClient is the interface for executing HTTP requests. +// Defaults to http.DefaultClient; override via SetHTTPClient for testing. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// httpParams holds the parsed inputs from WASM memory for an HTTP request. +type httpParams struct { + method string + url string + headers map[string]string + body io.Reader +} + +// readHTTPParams reads and validates the HTTP request parameters from WASM memory. +// Returns nil params and an error code on failure. +func readHTTPParams(mem api.Memory, + methodPtr, methodLen, urlPtr, urlLen, + headersPtr, headersLen, bodyPtr, bodyLen uint32, +) (*httpParams, int32) { + if methodLen > maxMethodBytes || urlLen > maxURLBytes || + headersLen > maxHeadersBytes || bodyLen > maxRequestBodyBytes { + return nil, httpErrInputTooLong + } + + methodData, ok := mem.Read(methodPtr, methodLen) + if !ok { + return nil, httpErrNetwork + } + urlData, ok := mem.Read(urlPtr, urlLen) + if !ok { + return nil, httpErrNetwork + } + + p := &httpParams{ + method: string(methodData), + url: string(urlData), + } + + if headersLen > 0 { + headersData, ok := mem.Read(headersPtr, headersLen) + if !ok { + return nil, httpErrNetwork + } + p.headers = parseHeaders(string(headersData)) + } + + if bodyLen > 0 { + bodyData, ok := mem.Read(bodyPtr, bodyLen) + if !ok { + return nil, httpErrNetwork + } + p.body = bytes.NewReader(bodyData) + } + + return p, 0 +} + +// writeSizeHint writes the response body length to the first 4 bytes of the +// response buffer so the agent can retry with a larger allocation. +func writeSizeHint(mem api.Memory, respPtr, respCap uint32, size int) { + if respCap >= 4 { + sizeBuf := make([]byte, 4) + binary.LittleEndian.PutUint32(sizeBuf, uint32(size)) + mem.Write(respPtr, sizeBuf) + } +} + +// registerHTTP registers the http_request hostcall on the igor WASM host module. +// +// ABI: +// +// http_request( +// method_ptr, method_len, +// url_ptr, url_len, +// headers_ptr, headers_len, +// body_ptr, body_len, +// resp_ptr, resp_cap +// ) -> i32 +// +// Returns HTTP status code (>0) on success, negative error code on failure. +// Response layout: [body_len: 4 bytes LE][body: N bytes]. +func (r *Registry) registerHTTP(builder wazero.HostModuleBuilder, capCfg manifest.CapabilityConfig) { + client := r.httpClient + if client == nil { + client = http.DefaultClient + } + + allowedHosts := extractAllowedHosts(capCfg) + timeoutMs := extractIntOption(capCfg.Options, "timeout_ms", defaultHTTPTimeoutMs) + maxRespBytes := extractIntOption(capCfg.Options, "max_response_bytes", defaultMaxResponseBytes) + + builder.NewFunctionBuilder(). + WithFunc(func(ctx context.Context, m api.Module, + methodPtr, methodLen, + urlPtr, urlLen, + headersPtr, headersLen, + bodyPtr, bodyLen, + respPtr, respCap uint32, + ) int32 { + params, errCode := readHTTPParams(m.Memory(), + methodPtr, methodLen, urlPtr, urlLen, + headersPtr, headersLen, bodyPtr, bodyLen) + if params == nil { + return errCode + } + + if err := checkAllowedHost(params.url, allowedHosts); err != nil { + r.logger.Warn("HTTP request blocked", "url", params.url, "error", err) + return httpErrHostBlocked + } + + timeout := time.Duration(timeoutMs) * time.Millisecond + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, params.method, params.url, params.body) + if err != nil { + r.logger.Error("HTTP request creation failed", "error", err) + return httpErrNetwork + } + for k, v := range params.headers { + req.Header.Set(k, v) + } + + resp, err := client.Do(req) + if err != nil { + if reqCtx.Err() != nil { + return httpErrTimeout + } + r.logger.Error("HTTP request failed", "error", err) + return httpErrNetwork + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, int64(maxRespBytes)+1)) + if err != nil { + r.logger.Error("HTTP response read failed", "error", err) + return httpErrNetwork + } + + if len(respBody) > maxRespBytes { + writeSizeHint(m.Memory(), respPtr, respCap, len(respBody)) + return httpErrRespTooLarge + } + + needed := uint32(4 + len(respBody)) + if needed > respCap { + writeSizeHint(m.Memory(), respPtr, respCap, len(respBody)) + return httpErrRespTooLarge + } + + // Write response: [body_len: 4 bytes LE][body: N bytes]. + out := make([]byte, needed) + binary.LittleEndian.PutUint32(out[:4], uint32(len(respBody))) + copy(out[4:], respBody) + if !m.Memory().Write(respPtr, out) { + return httpErrNetwork + } + + // Record observation for replay (CM-4). + obsPayload := make([]byte, 4+len(respBody)) + binary.LittleEndian.PutUint32(obsPayload[:4], uint32(resp.StatusCode)) + copy(obsPayload[4:], respBody) + r.eventLog.Record(eventlog.HTTPRequest, obsPayload) + + return int32(resp.StatusCode) + }). + Export("http_request") +} + +// extractAllowedHosts reads the allowed_hosts option from the capability config. +func extractAllowedHosts(cfg manifest.CapabilityConfig) []string { + if cfg.Options == nil { + return nil + } + raw, ok := cfg.Options["allowed_hosts"] + if !ok { + return nil + } + slice, ok := raw.([]any) + if !ok { + return nil + } + hosts := make([]string, 0, len(slice)) + for _, v := range slice { + if s, ok := v.(string); ok { + hosts = append(hosts, strings.ToLower(s)) + } + } + return hosts +} + +// extractIntOption reads an integer option with a default fallback. +func extractIntOption(opts map[string]any, key string, defaultVal int) int { + if opts == nil { + return defaultVal + } + raw, ok := opts[key] + if !ok { + return defaultVal + } + switch v := raw.(type) { + case float64: + return int(v) // JSON numbers decode as float64. + case int: + return v + default: + return defaultVal + } +} + +// checkAllowedHost validates the request URL against the allowed hosts list. +// If allowedHosts is empty, all hosts are permitted. +func checkAllowedHost(rawURL string, allowedHosts []string) error { + if len(allowedHosts) == 0 { + return nil + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + host := strings.ToLower(parsed.Hostname()) + for _, allowed := range allowedHosts { + if host == allowed { + return nil + } + } + return fmt.Errorf("host %q not in allowed_hosts", host) +} + +// parseHeaders parses "Key: Value\n" delimited headers into a map. +func parseHeaders(raw string) map[string]string { + headers := make(map[string]string) + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + headers[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1]) + } + return headers +} diff --git a/internal/hostcall/http_test.go b/internal/hostcall/http_test.go new file mode 100644 index 0000000..9946dcd --- /dev/null +++ b/internal/hostcall/http_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hostcall + +import ( + "testing" + + "github.com/simonovic86/igor/pkg/manifest" +) + +func TestExtractAllowedHosts(t *testing.T) { + tests := []struct { + name string + cfg manifest.CapabilityConfig + want []string + }{ + { + name: "nil options", + cfg: manifest.CapabilityConfig{}, + want: nil, + }, + { + name: "no allowed_hosts key", + cfg: manifest.CapabilityConfig{Options: map[string]any{"timeout_ms": 5000}}, + want: nil, + }, + { + name: "valid hosts", + cfg: manifest.CapabilityConfig{ + Options: map[string]any{ + "allowed_hosts": []any{"api.example.com", "httpbin.org"}, + }, + }, + want: []string{"api.example.com", "httpbin.org"}, + }, + { + name: "mixed types filtered", + cfg: manifest.CapabilityConfig{ + Options: map[string]any{ + "allowed_hosts": []any{"valid.com", 123, "also-valid.com"}, + }, + }, + want: []string{"valid.com", "also-valid.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractAllowedHosts(tt.cfg) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("host[%d] = %q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestCheckAllowedHost(t *testing.T) { + tests := []struct { + name string + url string + hosts []string + wantErr bool + }{ + { + name: "empty allowlist permits all", + url: "https://anything.com/path", + hosts: nil, + wantErr: false, + }, + { + name: "allowed host", + url: "https://api.example.com/v1/data", + hosts: []string{"api.example.com"}, + wantErr: false, + }, + { + name: "blocked host", + url: "https://evil.com/steal", + hosts: []string{"api.example.com"}, + wantErr: true, + }, + { + name: "case insensitive", + url: "https://API.Example.COM/v1", + hosts: []string{"api.example.com"}, + wantErr: false, + }, + { + name: "port stripped from hostname", + url: "https://api.example.com:8443/v1", + hosts: []string{"api.example.com"}, + wantErr: false, + }, + { + name: "invalid URL", + url: "://not-a-url", + hosts: []string{"anything.com"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := checkAllowedHost(tt.url, tt.hosts) + if (err != nil) != tt.wantErr { + t.Errorf("checkAllowedHost(%q, %v) error = %v, wantErr %v", tt.url, tt.hosts, err, tt.wantErr) + } + }) + } +} + +func TestParseHeaders(t *testing.T) { + tests := []struct { + name string + raw string + want map[string]string + }{ + { + name: "single header", + raw: "Content-Type: application/json\n", + want: map[string]string{"Content-Type": "application/json"}, + }, + { + name: "multiple headers", + raw: "Authorization: Bearer token\nAccept: text/plain\n", + want: map[string]string{"Authorization": "Bearer token", "Accept": "text/plain"}, + }, + { + name: "empty string", + raw: "", + want: map[string]string{}, + }, + { + name: "trailing newlines", + raw: "X-Custom: value\n\n\n", + want: map[string]string{"X-Custom": "value"}, + }, + { + name: "no colon skipped", + raw: "garbage\nX-Valid: yes\n", + want: map[string]string{"X-Valid": "yes"}, + }, + { + name: "value with colon", + raw: "Authorization: Basic dXNlcjpwYXNz\n", + want: map[string]string{"Authorization": "Basic dXNlcjpwYXNz"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseHeaders(tt.raw) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for k, wantV := range tt.want { + if gotV, ok := got[k]; !ok || gotV != wantV { + t.Errorf("header[%q] = %q, want %q", k, gotV, wantV) + } + } + }) + } +} + +func TestExtractIntOption(t *testing.T) { + tests := []struct { + name string + opts map[string]any + key string + defaultVal int + want int + }{ + {"nil opts", nil, "timeout_ms", 10000, 10000}, + {"missing key", map[string]any{"other": 5}, "timeout_ms", 10000, 10000}, + {"float64 value", map[string]any{"timeout_ms": float64(5000)}, "timeout_ms", 10000, 5000}, + {"int value", map[string]any{"timeout_ms": 3000}, "timeout_ms", 10000, 3000}, + {"wrong type", map[string]any{"timeout_ms": "fast"}, "timeout_ms", 10000, 10000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractIntOption(tt.opts, tt.key, tt.defaultVal) + if got != tt.want { + t.Errorf("got %d, want %d", got, tt.want) + } + }) + } +} diff --git a/internal/hostcall/registry.go b/internal/hostcall/registry.go index b7111b2..ac0b4f5 100644 --- a/internal/hostcall/registry.go +++ b/internal/hostcall/registry.go @@ -22,6 +22,7 @@ type Registry struct { eventLog *eventlog.EventLog walletState WalletState // optional; nil = wallet hostcalls not available pricingState PricingState // optional; nil = pricing hostcalls not available + httpClient HTTPClient // optional; nil = use http.DefaultClient } // NewRegistry creates a hostcall registry bound to the given event log. @@ -44,6 +45,12 @@ func (r *Registry) SetPricingState(ps PricingState) { r.pricingState = ps } +// SetHTTPClient installs a custom HTTP client for the http_request hostcall. +// If not set, http.DefaultClient is used. Useful for testing. +func (r *Registry) SetHTTPClient(c HTTPClient) { + r.httpClient = c +} + // RegisterHostModule builds and instantiates the "igor" WASM host module // with only the capabilities declared in the manifest. // Must be called after WASI instantiation and before agent module instantiation. @@ -90,6 +97,12 @@ func (r *Registry) RegisterHostModule( registered++ } + if m.Has("http") { + capCfg := m.Capabilities["http"] + r.registerHTTP(builder, capCfg) + registered++ + } + // Only instantiate if at least one capability was registered. // If the agent has an empty manifest, skip module creation entirely. // If the agent's WASM imports from "igor", instantiation will fail diff --git a/pkg/manifest/parse.go b/pkg/manifest/parse.go index 1175909..12128b2 100644 --- a/pkg/manifest/parse.go +++ b/pkg/manifest/parse.go @@ -12,7 +12,7 @@ import ( ) // NodeCapabilities lists capabilities available on the current node. -var NodeCapabilities = []string{"clock", "rand", "log", "wallet", "pricing"} +var NodeCapabilities = []string{"clock", "rand", "log", "wallet", "pricing", "http"} // ParseCapabilityManifest parses a capability manifest from JSON bytes. // An empty or nil input returns an empty manifest (no capabilities declared). diff --git a/sdk/igor/hostcalls_http_stub.go b/sdk/igor/hostcalls_http_stub.go new file mode 100644 index 0000000..b9405be --- /dev/null +++ b/sdk/igor/hostcalls_http_stub.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build !tinygo && !wasip1 + +package igor + +// HTTPRequest performs an HTTP request through the runtime. +// In non-WASM builds, dispatches to the registered MockBackend. +func HTTPRequest(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) { + if activeMock != nil { + return activeMock.HTTPRequest(method, url, headers, body) + } + panic("igor: HTTPRequest requires WASM runtime or mock (see sdk/igor/mock)") +} + +// HTTPGet performs an HTTP GET request. +// In non-WASM builds, dispatches to the registered MockBackend. +func HTTPGet(url string) (statusCode int, body []byte, err error) { + return HTTPRequest("GET", url, nil, nil) +} + +// HTTPPost performs an HTTP POST request with the given content type and body. +// In non-WASM builds, dispatches to the registered MockBackend. +func HTTPPost(url, contentType string, body []byte) (statusCode int, respBody []byte, err error) { + headers := map[string]string{"Content-Type": contentType} + return HTTPRequest("POST", url, headers, body) +} diff --git a/sdk/igor/hostcalls_http_wasm.go b/sdk/igor/hostcalls_http_wasm.go new file mode 100644 index 0000000..7056bd3 --- /dev/null +++ b/sdk/igor/hostcalls_http_wasm.go @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build tinygo || wasip1 + +package igor + +import ( + "encoding/binary" + "fmt" + "unsafe" +) + +// Raw WASM import for the HTTP hostcall from the igor host module. + +//go:wasmimport igor http_request +func httpRequest( + methodPtr, methodLen, + urlPtr, urlLen, + headersPtr, headersLen, + bodyPtr, bodyLen, + respPtr, respCap uint32, +) int32 + +// HTTPRequest performs an HTTP request through the runtime. +// Requires the "http" capability in the agent manifest. +// Returns the HTTP status code, response body, and any error. +// Headers are passed as a map; nil means no custom headers. +func HTTPRequest(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) { + methodBuf := []byte(method) + urlBuf := []byte(url) + + // Encode headers as "Key: Value\n" delimited. + var headersBuf []byte + if len(headers) > 0 { + var sb []byte + for k, v := range headers { + sb = append(sb, k...) + sb = append(sb, ": "...) + sb = append(sb, v...) + sb = append(sb, '\n') + } + headersBuf = sb + } + + // Initial response buffer: 8KB. + respBuf := make([]byte, 8192) + + rc := doHTTPRequest(methodBuf, urlBuf, headersBuf, body, respBuf) + + // If response too large, retry with the size hint. + if rc == -5 && len(respBuf) >= 4 { + needed := binary.LittleEndian.Uint32(respBuf[:4]) + if needed > 0 && needed <= 4*1024*1024 { // Cap retry at 4MB. + respBuf = make([]byte, needed+4) // +4 for the length prefix. + rc = doHTTPRequest(methodBuf, urlBuf, headersBuf, body, respBuf) + } + } + + if rc < 0 { + return 0, nil, fmt.Errorf("http_request failed: code %d", rc) + } + + // Parse response: [body_len: 4 bytes LE][body: N bytes]. + if len(respBuf) < 4 { + return int(rc), nil, nil + } + bodyLen := binary.LittleEndian.Uint32(respBuf[:4]) + if int(bodyLen)+4 > len(respBuf) { + return int(rc), nil, fmt.Errorf("http_request: response body truncated") + } + return int(rc), respBuf[4 : 4+bodyLen], nil +} + +// doHTTPRequest is the low-level call with pre-allocated buffers. +func doHTTPRequest(method, url, headers, body, resp []byte) int32 { + var methodPtr, urlPtr, headersPtr, bodyPtr, respPtr uint32 + var headersLen, bodyLen uint32 + + methodPtr = uint32(uintptr(unsafe.Pointer(&method[0]))) + urlPtr = uint32(uintptr(unsafe.Pointer(&url[0]))) + + if len(headers) > 0 { + headersPtr = uint32(uintptr(unsafe.Pointer(&headers[0]))) + headersLen = uint32(len(headers)) + } + if len(body) > 0 { + bodyPtr = uint32(uintptr(unsafe.Pointer(&body[0]))) + bodyLen = uint32(len(body)) + } + respPtr = uint32(uintptr(unsafe.Pointer(&resp[0]))) + + return httpRequest( + methodPtr, uint32(len(method)), + urlPtr, uint32(len(url)), + headersPtr, headersLen, + bodyPtr, bodyLen, + respPtr, uint32(len(resp)), + ) +} + +// HTTPGet performs an HTTP GET request. +// Convenience wrapper around HTTPRequest. +func HTTPGet(url string) (statusCode int, body []byte, err error) { + return HTTPRequest("GET", url, nil, nil) +} + +// HTTPPost performs an HTTP POST request with the given content type and body. +// Convenience wrapper around HTTPRequest. +func HTTPPost(url, contentType string, body []byte) (statusCode int, respBody []byte, err error) { + headers := map[string]string{"Content-Type": contentType} + return HTTPRequest("POST", url, headers, body) +} diff --git a/sdk/igor/mock/mock.go b/sdk/igor/mock/mock.go index b20bdb2..786ec6c 100644 --- a/sdk/igor/mock/mock.go +++ b/sdk/igor/mock/mock.go @@ -28,15 +28,19 @@ import ( igor "github.com/simonovic86/igor/sdk/igor" ) +// HTTPHandler is a function that handles mock HTTP requests. +type HTTPHandler func(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) + // Runtime provides mock implementations of Igor hostcalls for native testing. type Runtime struct { - mu sync.Mutex - clock func() int64 - randSrc *rand.Rand - logs []string - budget int64 - receipts [][]byte - nodePrice int64 + mu sync.Mutex + clock func() int64 + randSrc *rand.Rand + logs []string + budget int64 + receipts [][]byte + nodePrice int64 + httpHandler HTTPHandler } // New creates a mock runtime using the real system clock and crypto-seeded rand. @@ -184,3 +188,21 @@ func (r *Runtime) SetNodePrice(p int64) { defer r.mu.Unlock() r.nodePrice = p } + +// HTTPRequest implements MockBackend. +func (r *Runtime) HTTPRequest(method, url string, headers map[string]string, body []byte) (int, []byte, error) { + r.mu.Lock() + handler := r.httpHandler + r.mu.Unlock() + if handler != nil { + return handler(method, url, headers, body) + } + return 0, nil, fmt.Errorf("no HTTP handler configured (use SetHTTPHandler)") +} + +// SetHTTPHandler configures a function to handle mock HTTP requests. +func (r *Runtime) SetHTTPHandler(h HTTPHandler) { + r.mu.Lock() + defer r.mu.Unlock() + r.httpHandler = h +} diff --git a/sdk/igor/mock_backend.go b/sdk/igor/mock_backend.go index b5c0708..532706a 100644 --- a/sdk/igor/mock_backend.go +++ b/sdk/igor/mock_backend.go @@ -12,6 +12,7 @@ type MockBackend interface { WalletReceiptCount() int WalletReceipt(index int) ([]byte, error) NodePrice() int64 + HTTPRequest(method, url string, headers map[string]string, body []byte) (statusCode int, respBody []byte, err error) } // activeMock is set by mock.Enable() and cleared by mock.Disable().