use std::path::PathBuf;
use std::time::Duration;
use anyhow::{anyhow, bail, Context};
use futures_util::{SinkExt, StreamExt};
use serde_json::Value;
use tokio_tungstenite::tungstenite::Message;
use super::{Accumulator, CellRun, DirPngSink, KernelClient};
const DEFAULT_RUN_TIMEOUT: Duration = Duration::from_secs(120);
pub struct RestKernelClient {
ws_url: String,
kernel_id: String,
base_url: String,
plots_dir: PathBuf,
timeout: Duration,
session: String,
}
impl RestKernelClient {
pub async fn connect(
base_url: &str,
token: Option<&str>,
kernel_id: Option<&str>,
plots_dir: PathBuf,
) -> anyhow::Result<Self> {
let base = base_url.trim_end_matches('/').to_string();
let http = reqwest::Client::builder()
.build()
.context("building HTTP client for Jupyter REST")?;
let kernel_id = match kernel_id {
Some(id) => id.to_string(),
None => resolve_or_start_kernel(&http, &base, token).await?,
};
let ws_url = channels_ws_url(&base, &kernel_id, token)?;
Ok(Self {
ws_url,
kernel_id,
base_url: base,
plots_dir,
timeout: DEFAULT_RUN_TIMEOUT,
session: uuid::Uuid::new_v4().to_string(),
})
}
pub fn kernel_id(&self) -> &str {
&self.kernel_id
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
async fn run_cell_impl(&self, code: &str) -> anyhow::Result<CellRun> {
let request = self.ws_url.clone();
let (mut ws, _resp) = tokio_tungstenite::connect_async(&request)
.await
.with_context(|| {
format!(
"connecting to kernel channels websocket at {}",
self.base_url
)
})?;
let msg_id = uuid::Uuid::new_v4().to_string();
let execute = execute_request_message(&msg_id, &self.session, code);
ws.send(Message::text(execute.to_string()))
.await
.context("sending execute_request to kernel")?;
let mut sink = DirPngSink::new(self.plots_dir.clone());
let mut acc = Accumulator::new(&mut sink);
let read = async {
while let Some(frame) = ws.next().await {
let frame = frame.context("reading kernel websocket frame")?;
if frame.is_close() {
break;
}
let text = match message_text(frame) {
Some(t) => t,
None => continue,
};
let msg: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => continue,
};
if channel_of(&msg) == Some("iopub") && parent_msg_id(&msg) == Some(msg_id.as_str())
{
acc.feed(&msg)?;
if acc.is_idle() {
break;
}
}
}
if !acc.is_idle() {
bail!(
"kernel channels websocket closed before the cell finished \
(no status: idle); the run is truncated"
);
}
Ok::<(), anyhow::Error>(())
};
match tokio::time::timeout(self.timeout, read).await {
Ok(result) => result?,
Err(_) => bail!(
"timed out after {:?} waiting for kernel to finish the cell (no status: idle)",
self.timeout
),
}
let _ = ws.close(None).await;
Ok(acc.finish())
}
}
#[async_trait::async_trait]
impl KernelClient for RestKernelClient {
async fn run_cell(&self, code: &str) -> anyhow::Result<CellRun> {
self.run_cell_impl(code).await
}
}
async fn resolve_or_start_kernel(
http: &reqwest::Client,
base: &str,
token: Option<&str>,
) -> anyhow::Result<String> {
let list_url = format!("{base}/api/kernels");
let resp = with_token(http.get(&list_url), token)
.send()
.await
.with_context(|| format!("GET {list_url}"))?;
if !resp.status().is_success() {
bail!(
"Jupyter REST GET /api/kernels failed with HTTP {} (is the token correct?)",
resp.status()
);
}
let kernels: Value = resp
.json()
.await
.context("parsing /api/kernels response as JSON")?;
if let Some(id) = kernels
.as_array()
.and_then(|arr| arr.first())
.and_then(|k| k.get("id"))
.and_then(|id| id.as_str())
{
return Ok(id.to_string());
}
let start_url = format!("{base}/api/kernels");
let resp = with_token(http.post(&start_url), token)
.json(&serde_json::json!({}))
.send()
.await
.with_context(|| format!("POST {start_url}"))?;
if !resp.status().is_success() {
bail!(
"Jupyter REST POST /api/kernels failed with HTTP {} (no kernelspec available?)",
resp.status()
);
}
let started: Value = resp
.json()
.await
.context("parsing POST /api/kernels response as JSON")?;
started
.get("id")
.and_then(|id| id.as_str())
.map(str::to_string)
.ok_or_else(|| anyhow!("POST /api/kernels response carried no kernel id"))
}
fn with_token(builder: reqwest::RequestBuilder, token: Option<&str>) -> reqwest::RequestBuilder {
match token {
Some(tok) if !tok.is_empty() => builder.header("Authorization", format!("token {tok}")),
_ => builder,
}
}
fn channels_ws_url(base: &str, kernel_id: &str, token: Option<&str>) -> anyhow::Result<String> {
let parsed = url::Url::parse(base).with_context(|| format!("parsing base URL {base}"))?;
let ws_scheme = match parsed.scheme() {
"https" => "wss",
"http" => "ws",
other => bail!("unsupported Jupyter base-URL scheme {other:?} (expected http/https)"),
};
let authority = parsed
.host_str()
.ok_or_else(|| anyhow!("base URL {base} has no host"))?;
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
let path = parsed.path().trim_end_matches('/');
let mut ws_url =
format!("{ws_scheme}://{authority}{port}{path}/api/kernels/{kernel_id}/channels");
if let Some(tok) = token {
if !tok.is_empty() {
ws_url.push_str(&format!("?token={tok}"));
}
}
Ok(ws_url)
}
fn execute_request_message(msg_id: &str, session: &str, code: &str) -> Value {
serde_json::json!({
"channel": "shell",
"header": {
"msg_id": msg_id,
"session": session,
"username": "newt",
"msg_type": "execute_request",
"version": "5.3"
},
"parent_header": {},
"metadata": {},
"content": {
"code": code,
"silent": false,
"store_history": true,
"user_expressions": {},
"allow_stdin": false,
"stop_on_error": true
}
})
}
fn message_text(msg: Message) -> Option<String> {
match msg {
Message::Text(t) => Some(t),
_ => None,
}
}
fn channel_of(msg: &Value) -> Option<&str> {
msg.get("channel").and_then(|c| c.as_str())
}
fn parent_msg_id(msg: &Value) -> Option<&str> {
msg.get("parent_header")
.and_then(|p| p.get("msg_id"))
.and_then(|id| id.as_str())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn channels_ws_url_swaps_http_to_ws_and_appends_token() {
let url = channels_ws_url("http://127.0.0.1:8888", "abc123", Some("sekret")).unwrap();
assert_eq!(
url,
"ws://127.0.0.1:8888/api/kernels/abc123/channels?token=sekret"
);
}
#[test]
fn channels_ws_url_swaps_https_to_wss() {
let url = channels_ws_url("https://node.lab", "k", None).unwrap();
assert_eq!(url, "wss://node.lab/api/kernels/k/channels");
}
#[test]
fn channels_ws_url_preserves_base_path_prefix() {
let url = channels_ws_url("http://hub:8000/user/alice/", "k", Some("t")).unwrap();
assert_eq!(
url,
"ws://hub:8000/user/alice/api/kernels/k/channels?token=t"
);
}
#[test]
fn channels_ws_url_rejects_non_http_scheme() {
assert!(channels_ws_url("ftp://nope", "k", None).is_err());
}
#[test]
fn execute_request_carries_code_and_msg_id() {
let msg = execute_request_message("mid-1", "sess-1", "print(1)");
assert_eq!(msg["channel"], "shell");
assert_eq!(msg["header"]["msg_id"], "mid-1");
assert_eq!(msg["header"]["session"], "sess-1");
assert_eq!(msg["header"]["msg_type"], "execute_request");
assert_eq!(msg["content"]["code"], "print(1)");
assert_eq!(msg["content"]["silent"], false);
}
#[test]
fn channel_and_parent_msg_id_extract() {
let msg = serde_json::json!({
"channel": "iopub",
"parent_header": { "msg_id": "mid-1" }
});
assert_eq!(channel_of(&msg), Some("iopub"));
assert_eq!(parent_msg_id(&msg), Some("mid-1"));
let bare = serde_json::json!({});
assert_eq!(channel_of(&bare), None);
assert_eq!(parent_msg_id(&bare), None);
}
#[test]
fn message_text_only_for_text_frames() {
assert_eq!(
message_text(Message::text("hi".to_string())),
Some("hi".to_string())
);
assert_eq!(message_text(Message::Ping(Default::default())), None);
}
}