diff --git a/.agents/skills/nemo-xml-reference/SKILL.md b/.agents/skills/nemo-xml-reference/SKILL.md index 8c1a05c..6d2f97e 100644 --- a/.agents/skills/nemo-xml-reference/SKILL.md +++ b/.agents/skills/nemo-xml-reference/SKILL.md @@ -142,6 +142,8 @@ Expressions use `${...}` in attribute values: + diff --git a/crates/nemo-data/src/sources/http.rs b/crates/nemo-data/src/sources/http.rs index e8364c7..05db435 100644 --- a/crates/nemo-data/src/sources/http.rs +++ b/crates/nemo-data/src/sources/http.rs @@ -294,6 +294,37 @@ mod tests { assert!(config.headers.is_empty()); } + #[test] + fn test_parse_http_headers_object() { + use nemo_config::Value; + let mut obj = indexmap::IndexMap::new(); + obj.insert("Authorization".to_string(), Value::from("Bearer abc")); + obj.insert("X-Count".to_string(), Value::from(3i64)); + let value = Value::Object(obj); + let headers = crate::sources::parse_http_headers(Some(&value)); + assert_eq!( + headers.get("Authorization").map(String::as_str), + Some("Bearer abc") + ); + assert_eq!(headers.get("X-Count").map(String::as_str), Some("3")); + } + + #[test] + fn test_parse_http_headers_json_string() { + use nemo_config::Value; + let value = Value::from(r#"{"Authorization":"Bearer xyz"}"#); + let headers = crate::sources::parse_http_headers(Some(&value)); + assert_eq!( + headers.get("Authorization").map(String::as_str), + Some("Bearer xyz") + ); + } + + #[test] + fn test_parse_http_headers_none() { + assert!(crate::sources::parse_http_headers(None).is_empty()); + } + #[test] fn test_http_source_creation() { let config = HttpSourceConfig { diff --git a/crates/nemo-data/src/sources/mod.rs b/crates/nemo-data/src/sources/mod.rs index 957d72e..3bc0a53 100644 --- a/crates/nemo-data/src/sources/mod.rs +++ b/crates/nemo-data/src/sources/mod.rs @@ -9,7 +9,7 @@ mod timer; mod websocket; pub use self::file::{FileFormat, FileSource, FileSourceConfig}; -pub use self::http::{HttpSource, HttpSourceConfig}; +pub use self::http::{HttpMethod, HttpSource, HttpSourceConfig}; pub use self::mqtt::{MqttSource, MqttSourceConfig}; pub use self::nats::{NatsSource, NatsSourceConfig}; pub use self::redis::{RedisSource, RedisSourceConfig}; @@ -18,6 +18,55 @@ pub use self::websocket::{WebSocketSource, WebSocketSourceConfig}; use crate::source::DataSource; use nemo_config::Value; +use std::collections::HashMap; + +/// Parses the `headers` property of an HTTP source into a string map. +/// +/// Accepts either a config object (``-style +/// nested attributes) or a JSON-string attribute +/// (`headers='{"Authorization":"Bearer …"}'`). Non-string values are stringified +/// so numeric header values are tolerated. Header values authored as +/// `${env.TOKEN}` / `${var.x}` are already resolved by the config resolver before +/// reaching here. +fn parse_http_headers(value: Option<&Value>) -> HashMap { + let mut headers = HashMap::new(); + + let obj = match value { + Some(v) => { + if let Some(obj) = v.as_object() { + obj.clone() + } else if let Some(s) = v.as_str() { + // JSON-string form: parse and recurse on the parsed object. + match serde_json::from_str::(s) { + Ok(json) if json.is_object() => { + return parse_http_headers(Some(&Value::from(json))); + } + _ => return headers, + } + } else { + return headers; + } + } + None => return headers, + }; + + for (key, val) in obj { + // Header values are strings; tolerate scalar JSON values by stringifying. + let s = if let Some(s) = val.as_str() { + s.to_string() + } else if let Some(i) = val.as_i64() { + i.to_string() + } else if let Some(f) = val.as_f64() { + f.to_string() + } else if let Some(b) = val.as_bool() { + b.to_string() + } else { + continue; + }; + headers.insert(key, s); + } + headers +} /// Creates a DataSource from a type name and XML configuration. /// @@ -49,9 +98,29 @@ pub fn create_source(name: &str, source_type: &str, config: &Value) -> Option HttpMethod::Post, + "PUT" => HttpMethod::Put, + "PATCH" => HttpMethod::Patch, + "DELETE" => HttpMethod::Delete, + _ => HttpMethod::Get, + }; + + let headers = parse_http_headers(config.get("headers")); + let body = config.get("body").cloned(); + let cfg = HttpSourceConfig { id: name.to_string(), url, + method, + headers, + body, interval, ..Default::default() }; diff --git a/crates/nemo-extension/src/rhai_engine.rs b/crates/nemo-extension/src/rhai_engine.rs index fd0d6d3..9e278d0 100644 --- a/crates/nemo-extension/src/rhai_engine.rs +++ b/crates/nemo-extension/src/rhai_engine.rs @@ -561,9 +561,17 @@ impl RhaiEngine { /// # Functions registered /// /// - `http_get(url: &str) -> Dynamic` — GET request, returns parsed JSON or string + /// - `http_get(url: &str, headers: Map) -> Dynamic` — GET with request headers /// - `http_post(url: &str, body: &str) -> Dynamic` — POST with JSON body + /// - `http_post(url: &str, body: &str, headers: Map) -> Dynamic` — POST with headers /// - `http_put(url: &str, body: &str) -> Dynamic` — PUT with JSON body + /// - `http_put(url: &str, body: &str, headers: Map) -> Dynamic` — PUT with headers /// - `http_delete(url: &str) -> Dynamic` — DELETE request + /// - `http_delete(url: &str, headers: Map) -> Dynamic` — DELETE with headers + /// + /// The `headers` map lets scripts send arbitrary request headers, e.g. + /// `http_get(url, #{ "Authorization": "Bearer " + token })`. A caller-supplied + /// `Content-Type` overrides the JSON default applied to request bodies. /// /// All functions return a map with `{status, body, ok}` on success, or /// a map with `{error}` on failure. @@ -575,32 +583,79 @@ impl RhaiEngine { let c = client.clone(); self.engine .register_fn("http_get", move |url: &str| -> Dynamic { - execute_http_request(&h, &c, reqwest::Method::GET, url, None) + execute_http_request(&h, &c, reqwest::Method::GET, url, None, None) }); + // http_get(url, headers) -> Dynamic + let h = handle.clone(); + let c = client.clone(); + self.engine.register_fn( + "http_get", + move |url: &str, headers: rhai::Map| -> Dynamic { + execute_http_request(&h, &c, reqwest::Method::GET, url, None, Some(headers)) + }, + ); + // http_post(url, body) -> Dynamic let h = handle.clone(); let c = client.clone(); self.engine .register_fn("http_post", move |url: &str, body: &str| -> Dynamic { - execute_http_request(&h, &c, reqwest::Method::POST, url, Some(body)) + execute_http_request(&h, &c, reqwest::Method::POST, url, Some(body), None) }); + // http_post(url, body, headers) -> Dynamic + let h = handle.clone(); + let c = client.clone(); + self.engine.register_fn( + "http_post", + move |url: &str, body: &str, headers: rhai::Map| -> Dynamic { + execute_http_request( + &h, + &c, + reqwest::Method::POST, + url, + Some(body), + Some(headers), + ) + }, + ); + // http_put(url, body) -> Dynamic let h = handle.clone(); let c = client.clone(); self.engine .register_fn("http_put", move |url: &str, body: &str| -> Dynamic { - execute_http_request(&h, &c, reqwest::Method::PUT, url, Some(body)) + execute_http_request(&h, &c, reqwest::Method::PUT, url, Some(body), None) }); + // http_put(url, body, headers) -> Dynamic + let h = handle.clone(); + let c = client.clone(); + self.engine.register_fn( + "http_put", + move |url: &str, body: &str, headers: rhai::Map| -> Dynamic { + execute_http_request(&h, &c, reqwest::Method::PUT, url, Some(body), Some(headers)) + }, + ); + // http_delete(url) -> Dynamic let h = handle.clone(); let c = client.clone(); self.engine .register_fn("http_delete", move |url: &str| -> Dynamic { - execute_http_request(&h, &c, reqwest::Method::DELETE, url, None) + execute_http_request(&h, &c, reqwest::Method::DELETE, url, None, None) }); + + // http_delete(url, headers) -> Dynamic + let h = handle.clone(); + let c = client.clone(); + self.engine.register_fn( + "http_delete", + move |url: &str, headers: rhai::Map| -> Dynamic { + execute_http_request(&h, &c, reqwest::Method::DELETE, url, None, Some(headers)) + }, + ); } /// Lists all loaded script IDs. @@ -698,16 +753,46 @@ fn execute_http_request( method: reqwest::Method, url: &str, body: Option<&str>, + headers: Option, ) -> Dynamic { let url_string = url.to_string(); let body = body.map(|s| s.to_string()); let client = client.clone(); let method_clone = method.clone(); + // Flatten the header map to owned string pairs before crossing the async + // boundary (rhai::Map/Dynamic are not `Send`). Non-string values are + // stringified so callers can pass e.g. numeric header values. + let header_pairs: Vec<(String, String)> = headers + .map(|map| { + map.into_iter() + .map(|(k, v)| { + let value = if v.is_string() { + v.into_string().unwrap_or_default() + } else { + v.to_string() + }; + (k.to_string(), value) + }) + .collect() + }) + .unwrap_or_default(); + + let has_content_type = header_pairs + .iter() + .any(|(k, _)| k.eq_ignore_ascii_case("content-type")); + let result = handle.block_on(async move { let mut builder = client.request(method_clone, &url_string); if let Some(b) = body { - builder = builder.header("Content-Type", "application/json").body(b); + // Default the body content type to JSON unless the caller set it. + if !has_content_type { + builder = builder.header("Content-Type", "application/json"); + } + builder = builder.body(b); + } + for (key, value) in header_pairs { + builder = builder.header(key, value); } builder.send().await }); diff --git a/crates/nemo-registry/src/builtins.rs b/crates/nemo-registry/src/builtins.rs index 1546fb0..ae9f4f3 100644 --- a/crates/nemo-registry/src/builtins.rs +++ b/crates/nemo-registry/src/builtins.rs @@ -1030,6 +1030,11 @@ pub fn register_builtin_data_sources(registry: &ComponentRegistry) { .property("url", PropertySchema::string()) .property("method", PropertySchema::string().with_default("GET")) .property("interval", PropertySchema::integer().with_default(0i64)) + // Free-form request headers, e.g. `Authorization: Bearer …`. Accepts a + // config object or a JSON-string attribute; values may use `${env.X}` / + // `${var.x}` interpolation resolved at config load time. + .property("headers", PropertySchema::any()) + .property("body", PropertySchema::any()) .require("url"); let _ = registry.register_data_source(http); diff --git a/docs/knowledgebase/concepts/data-flow.md b/docs/knowledgebase/concepts/data-flow.md index 91ce098..a6c8ff6 100644 --- a/docs/knowledgebase/concepts/data-flow.md +++ b/docs/knowledgebase/concepts/data-flow.md @@ -24,7 +24,10 @@ Sources implement the async `DataSource` trait `sources/mod.rs`): * **Polling** — `timer` (periodic ticks), `http` (one-shot, or polling when - `interval` is set; reqwest). + `interval` is set; reqwest). The `http` source accepts `method`, `body`, and a + `headers` map (config object or JSON-string attribute) for per-request auth — + header values may use `${env.X}`/`${var.x}` (resolved at config load time). + Parsed in `create_source` (`crates/nemo-data/src/sources/mod.rs`). * **Streaming** — `websocket` (tokio-tungstenite, auto-reconnect), `mqtt` (rumqttc), `redis` (pub/sub), `nats` (subjects). * **Hybrid** — `file` (JSON/YAML/TOML/CSV/lines/raw, optional `notify` watch). diff --git a/docs/knowledgebase/concepts/extensions.md b/docs/knowledgebase/concepts/extensions.md index d7d2283..b017592 100644 --- a/docs/knowledgebase/concepts/extensions.md +++ b/docs/knowledgebase/concepts/extensions.md @@ -114,6 +114,14 @@ behind Cargo features on `nemo-extension`: | `science` | [`rhai-sci`](https://crates.io/crates/rhai-sci) | `pkg-sci` | `mean`, `std`, `median`, `linspace`, matrix ops, regression, SVD, … (compiled with `default-features = false` to avoid polars/nalgebra) | | `network` | _(reserved)_ | — | HTTP is already available via built-in `http_get`/`http_post`/`http_put`/`http_delete` | +The built-in HTTP helpers each take an optional trailing `headers` map, so +scripts can send auth/custom headers: +`http_get(url, #{ "Authorization": "Bearer " + token })` and +`http_post(url, body, #{ ... })` (likewise `http_put`/`http_delete`). A +caller-supplied `Content-Type` overrides the `application/json` default applied +to request bodies. The wiring lives in `execute_http_request` / +`register_http_functions` (`crates/nemo-extension/src/rhai_engine.rs`). + `rhai-chrono` (date/time) is **always** registered — it is pure and touches no host state. `json_parse` / `json_stringify` are also always available. diff --git a/docs/knowledgebase/log.md b/docs/knowledgebase/log.md index 1d711ae..77b26a3 100644 --- a/docs/knowledgebase/log.md +++ b/docs/knowledgebase/log.md @@ -1,6 +1,7 @@ # Knowledge Base Update Log ## 2026-07-31 +* **Fix**: Added request-header support to HTTP helpers and the `http` data source ([issue #80](https://github.com/geoffjay/nemo/issues/80)) — previously there was no way to send `Authorization: Bearer …`. **Rhai** (`crates/nemo-extension/src/rhai_engine.rs`): `execute_http_request` gained a `headers: Option` param (flattened to owned `(String,String)` pairs before the async boundary since `Dynamic` is `!Send`), and each helper got a header-accepting arity — `http_get(url, #{…})`, `http_post(url, body, #{…})`, `http_put(url, body, #{…})`, `http_delete(url, #{…})`. A caller-supplied `Content-Type` overrides the JSON default (case-insensitive check avoids a duplicate header). **Data source** (`crates/nemo-data/src/sources/mod.rs`): the `http` factory previously parsed only `url`/`interval` and silently dropped `method`/`headers`/`body` (the `HttpSourceConfig` fields + `fetch()` already applied them). Now parses all three; new `parse_http_headers` accepts a config object *or* a JSON-string attribute, tolerates scalar values, and returns a `HashMap`. Header values authored as `${env.X}`/`${var.x}` are already resolved by the config resolver before reaching the factory. **Schema** (`crates/nemo-registry/src/builtins.rs`): added `headers` + `body` (`PropertySchema::any()`) to the `http` data source schema so configs validate. Tests: +3 `parse_http_headers` (object / JSON-string / none). Affected crates build clean; nemo-data suite green. Updated [data flow](/docs/knowledgebase/concepts/data-flow.md), [extensions](/docs/knowledgebase/concepts/extensions.md), the XML reference skill, and `docs/public/configuration.md`. * **Plan**: Added [Screenshot as a plugin](/docs/knowledgebase/plans/screenshot-as-plugin.md) — a design to make `nemo screenshot` deployable on a **stock release binary** by moving it out of the feature-gated host build into an **OS-native capture plugin**. Grounded the blocker in code: capture today calls gpui's `Window::render_to_image()` (`commands/screenshot.rs:85`), gated `#[cfg(feature="test-support")]` inside gpui and kept out of release by decision; a native plugin only ever receives `PluginContext` (`nemo-plugin-api/src/lib.rs:285`) — no `&mut Window`, and it can't flip a host compile-time feature — so a plugin **must capture at the OS level** (ScreenCaptureKit / `CGWindowListCreateImage`), not through gpui. Two new host primitives designed: (1) **`Capability::Command`** + `CommandSpec`/`register_command`/`PluginCommandFn` so plugins contribute CLI subcommands — requires a two-phase parse ahead of the static clap match (`main.rs:66`, `args.rs`) with **built-ins taking precedence**; (2) a host **`capture_app_window`** `PluginContext` method (default `Unsupported`, matching the `navigate` pattern at `:327`) that owns the gpui run loop + `build_app_window`/`BootstrapParams` (`main.rs:246/265`) and returns a `CapturedFrame` of raw RGBA — recommended split is host-captures/plugin-encodes, which lets the OS-capture backend ship in the **default binary** (no `test-support`) while the plugin supplies CLI surface + PNG/permission policy. Open questions logged: TCC permission UX for CI, discovery cost, native ABI versioning (no check today, `:538`), WASM parity. Registered in both plan indexes. Planning only — nothing implemented. * **Fix**: Wired `` inside `` ([issue #83](https://github.com/geoffjay/nemo/issues/83)). The build path in `app.rs` collected only each menu item's `label` into a `Vec`, so `DropdownButton` rendered click-less `PopupMenuItem`s and every entry was a silent no-op. Now `app.rs` collects a `Vec` (new struct in `components/dropdown_button.rs`, re-exported from `components/mod.rs`), carrying each item's `handlers.get("click")`, and passes `runtime` + `entity_id` into `DropdownButton` (same as `