Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .agents/skills/nemo-xml-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ Expressions use `${...}` in attribute values:
<data>
<source name="ticker" type="timer" interval="1" /> <!-- tick every 1 second -->
<source name="api" type="http" url="https://api.example.com" interval="30" /> <!-- poll every 30 seconds -->
<source name="secure" type="http" url="https://api.example.com/me"
headers='{"Authorization":"Bearer ${env.API_TOKEN}"}' /> <!-- headers: object or JSON string; ${env.X}/${var.x} resolved at load -->
<source name="live" type="websocket" url="ws://localhost:8080" />
<source name="events" type="mqtt" url="mqtt://localhost:1883" topic="sensors/#" />
<source name="cache" type="redis" url="redis://localhost:6379" channel="updates" />
Expand Down
31 changes: 31 additions & 0 deletions crates/nemo-data/src/sources/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
71 changes: 70 additions & 1 deletion crates/nemo-data/src/sources/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -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 (`<headers Authorization="Bearer …" />`-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<String, String> {
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::<serde_json::Value>(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.
///
Expand Down Expand Up @@ -49,9 +98,29 @@ pub fn create_source(name: &str, source_type: &str, config: &Value) -> Option<Bo
.and_then(|v| v.as_i64())
.map(|secs| std::time::Duration::from_secs(secs as u64));

let method = match config
.get("method")
.and_then(|v| v.as_str())
.unwrap_or("GET")
.to_ascii_uppercase()
.as_str()
{
"POST" => 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()
};
Expand Down
95 changes: 90 additions & 5 deletions crates/nemo-extension/src/rhai_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -698,16 +753,46 @@ fn execute_http_request(
method: reqwest::Method,
url: &str,
body: Option<&str>,
headers: Option<rhai::Map>,
) -> 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
});
Expand Down
5 changes: 5 additions & 0 deletions crates/nemo-registry/src/builtins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
5 changes: 4 additions & 1 deletion docs/knowledgebase/concepts/data-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions docs/knowledgebase/concepts/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions docs/knowledgebase/log.md
Original file line number Diff line number Diff line change
@@ -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<rhai::Map>` 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 `<menu-item on-click>` inside `<dropdown-button>` ([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<String>`, so `DropdownButton` rendered click-less `PopupMenuItem`s and every entry was a silent no-op. Now `app.rs` collects a `Vec<MenuItem { id, label, on_click }>` (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 `<button>`). `DropdownButton::render` attaches `PopupMenuItem::on_click` that calls `runtime.call_handler(&handler, &item_id, "click")` + `cx.notify(entity_id)`, mirroring the button path. No registry change — `on-*` handler attributes are generic (already passed `nemo validate`). Updated the component doc-comment to show `on-click`.

Expand Down
2 changes: 1 addition & 1 deletion docs/public/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ access. The `features` attribute opts in to host-access packages:
| `file-io` | [`rhai-fs`](https://crates.io/crates/rhai-fs) | File read/write (`open_file`, `read_string`, `write`, `exists`, `create_dir`, `cwd`, `path`, …) |
| `system` | [`rhai-env`](https://crates.io/crates/rhai-env), [`rhai-process`](https://crates.io/crates/rhai-process) | Environment variables (`env`, `envs`, `set_env`) and subprocess execution (`cmd([...]).pipe(...).build().run()`) |
| `science` | [`rhai-sci`](https://crates.io/crates/rhai-sci) | Scientific computing (`mean`, `std`, `median`, `linspace`, matrix ops, regression, SVD, …) |
| `network` | _(reserved)_ | HTTP is already available via built-in `http_get`/`http_post`/`http_put`/`http_delete` |
| `network` | _(reserved)_ | HTTP is already available via built-in `http_get`/`http_post`/`http_put`/`http_delete`, each with an optional trailing `headers` map (e.g. `http_get(url, #{ "Authorization": "Bearer " + token })`) |

The [`rhai-chrono`](https://crates.io/crates/rhai-chrono) package (date/time
arithmetic: `datetime_utc`, `datetime_parse`, `format`, `timedelta_days`, …)
Expand Down
Loading