parent
8efdcab812
commit
d79581530b
@ -0,0 +1,66 @@ |
||||
-- Lua-side of the http module. |
||||
-- http.request (stateless) and http.session (constructor) are provided by Rust |
||||
-- before this runs. This layer adds resp.json(), method shorthands, the |
||||
-- JSON/postJSON helpers, and a metatable for session objects. |
||||
|
||||
-- Attach resp.json() to a response table: parses resp.body via utils.fromJSON. |
||||
local function wrap_resp(resp) |
||||
resp.json = function() return utils.fromJSON(resp.body) end |
||||
return resp |
||||
end |
||||
|
||||
-- Wrap the stateless http.request so responses carry resp.json(). |
||||
local _request = http.request |
||||
http.request = function(method, url, opts) |
||||
return wrap_resp(_request(method, url, opts)) |
||||
end |
||||
|
||||
-- Method shorthands for the stateless module. |
||||
for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do |
||||
http[m] = function(url, opts) return http.request(m:upper(), url, opts) end |
||||
end |
||||
|
||||
function http.getJSON(url, opts) |
||||
local resp = http.get(url, opts) |
||||
return resp.json(), resp |
||||
end |
||||
|
||||
function http.postJSON(url, body, opts) |
||||
opts = opts or {} |
||||
opts.json = body |
||||
return http.post(url, opts) |
||||
end |
||||
|
||||
-- Session metatable. http.session() returns a raw Rust table whose methods are |
||||
-- _request, save, load, clearCookies and cookies. The metatable adds the |
||||
-- request wrapper (for resp.json()) and the method shorthands on top. |
||||
local session_mt = {} |
||||
session_mt.__index = session_mt |
||||
|
||||
function session_mt:request(method, url, opts) |
||||
return wrap_resp(self:_request(method, url, opts)) |
||||
end |
||||
|
||||
function session_mt:get(url, opts) return self:request("GET", url, opts) end |
||||
function session_mt:post(url, opts) return self:request("POST", url, opts) end |
||||
function session_mt:put(url, opts) return self:request("PUT", url, opts) end |
||||
function session_mt:patch(url, opts) return self:request("PATCH", url, opts) end |
||||
function session_mt:delete(url, opts) return self:request("DELETE", url, opts) end |
||||
function session_mt:head(url, opts) return self:request("HEAD", url, opts) end |
||||
|
||||
function session_mt:getJSON(url, opts) |
||||
local resp = self:get(url, opts) |
||||
return resp.json(), resp |
||||
end |
||||
|
||||
function session_mt:postJSON(url, body, opts) |
||||
opts = opts or {} |
||||
opts.json = body |
||||
return self:post(url, opts) |
||||
end |
||||
|
||||
-- Wrap the Rust session constructor to install the metatable. |
||||
local _session = http.session |
||||
http.session = function(path) |
||||
return setmetatable(_session(path), session_mt) |
||||
end |
||||
@ -0,0 +1,529 @@ |
||||
use std::collections::HashMap; |
||||
use std::sync::{Arc, Mutex}; |
||||
use std::time::Duration; |
||||
|
||||
use mlua::prelude::LuaResult; |
||||
use mlua::{Lua, Table as LuaTable, Value as LuaValue}; |
||||
|
||||
use crate::stdlib::utils::lua_to_json; |
||||
|
||||
const HTTP_LUA: &str = include_str!("../../lua/stdlib/http.lua"); |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cookie jar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A minimal in-memory cookie jar: bare domain (no leading dot) → name → value.
|
||||
/// Deliberately simple — it covers the >95% case of `Set-Cookie` flows without
|
||||
/// pulling in the `cookie_store` crate, and serializes cleanly to JSONL.
|
||||
#[derive(Default)] |
||||
struct CookieJar { |
||||
cookies: HashMap<String, HashMap<String, String>>, |
||||
} |
||||
|
||||
impl CookieJar { |
||||
/// All cookies (name, value) whose stored domain matches `host`: either an
|
||||
/// exact match or `host` being a subdomain of the stored domain.
|
||||
fn cookies_for(&self, host: &str) -> Vec<(String, String)> { |
||||
let mut out = Vec::new(); |
||||
for (domain, names) in &self.cookies { |
||||
let suffix = format!(".{domain}"); |
||||
if host == domain || host.ends_with(&suffix) { |
||||
for (name, value) in names { |
||||
out.push((name.clone(), value.clone())); |
||||
} |
||||
} |
||||
} |
||||
out |
||||
} |
||||
|
||||
/// Parse a single `Set-Cookie` header value and store the cookie. Extracts
|
||||
/// `name=value` (first segment) and an optional `Domain=` attribute, falling
|
||||
/// back to the request host. Malformed headers are ignored.
|
||||
fn set_from_header(&mut self, host: &str, header: &str) { |
||||
let mut segments = header.split(';'); |
||||
let first = match segments.next() { |
||||
Some(s) => s.trim(), |
||||
None => return, |
||||
}; |
||||
let (name, value) = match first.split_once('=') { |
||||
Some((n, v)) => (n.trim(), v.trim()), |
||||
None => return, |
||||
}; |
||||
if name.is_empty() { |
||||
return; |
||||
} |
||||
|
||||
let mut domain = host.to_ascii_lowercase(); |
||||
for seg in segments { |
||||
if let Some((k, v)) = seg.split_once('=') |
||||
&& k.trim().eq_ignore_ascii_case("domain") |
||||
{ |
||||
let d = v.trim().trim_start_matches('.').to_ascii_lowercase(); |
||||
if !d.is_empty() { |
||||
domain = d; |
||||
} |
||||
} |
||||
} |
||||
|
||||
self.cookies |
||||
.entry(domain) |
||||
.or_default() |
||||
.insert(name.to_string(), value.to_string()); |
||||
} |
||||
|
||||
/// Serialize as JSONL — one `{"domain","name","value"}` object per line.
|
||||
fn to_jsonl(&self) -> String { |
||||
let mut out = String::new(); |
||||
for (domain, names) in &self.cookies { |
||||
for (name, value) in names { |
||||
let obj = serde_json::json!({ |
||||
"domain": domain, |
||||
"name": name, |
||||
"value": value, |
||||
}); |
||||
out.push_str(&obj.to_string()); |
||||
out.push('\n'); |
||||
} |
||||
} |
||||
out |
||||
} |
||||
|
||||
/// Merge cookies from JSONL produced by `to_jsonl`. Bad lines are skipped.
|
||||
fn merge_jsonl(&mut self, src: &str) { |
||||
for line in src.lines() { |
||||
let line = line.trim(); |
||||
if line.is_empty() { |
||||
continue; |
||||
} |
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { |
||||
continue; |
||||
}; |
||||
let domain = v.get("domain").and_then(|x| x.as_str()); |
||||
let name = v.get("name").and_then(|x| x.as_str()); |
||||
let value = v.get("value").and_then(|x| x.as_str()); |
||||
if let (Some(d), Some(n), Some(val)) = (domain, name, value) { |
||||
self.cookies |
||||
.entry(d.to_string()) |
||||
.or_default() |
||||
.insert(n.to_string(), val.to_string()); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request execution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `application/x-www-form-urlencoded` body from key/value pairs. Implemented
|
||||
/// inline so the build needs no optional reqwest features.
|
||||
fn form_urlencode(pairs: &[(String, String)]) -> String { |
||||
fn encode(s: &str) -> String { |
||||
let mut out = String::new(); |
||||
for b in s.bytes() { |
||||
match b { |
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { |
||||
out.push(b as char) |
||||
} |
||||
b' ' => out.push('+'), |
||||
_ => out.push_str(&format!("%{b:02X}")), |
||||
} |
||||
} |
||||
out |
||||
} |
||||
pairs |
||||
.iter() |
||||
.map(|(k, v)| format!("{}={}", encode(k), encode(v))) |
||||
.collect::<Vec<_>>() |
||||
.join("&") |
||||
} |
||||
|
||||
const MAX_REDIRECTS: u32 = 10; |
||||
|
||||
/// Shared by the stateless `http.request` and a session's `:request`. When `jar`
|
||||
/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses
|
||||
/// are stored back.
|
||||
///
|
||||
/// Redirects are followed manually (the client is built with
|
||||
/// `redirect::Policy::none`) so that `Set-Cookie` headers on 30x responses —
|
||||
/// the common login → redirect → dashboard pattern — are captured into the jar,
|
||||
/// which reqwest's transparent redirect following would otherwise hide.
|
||||
async fn execute_request( |
||||
lua: Lua, |
||||
client: reqwest::Client, |
||||
jar: Option<Arc<Mutex<CookieJar>>>, |
||||
method: String, |
||||
url: String, |
||||
opts: Option<LuaTable>, |
||||
) -> LuaResult<LuaTable> { |
||||
let mut method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) |
||||
.map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?; |
||||
|
||||
// Parse opts once into owned pieces so each redirect hop can rebuild the
|
||||
// request (a reqwest RequestBuilder is single-use).
|
||||
let mut timeout: Option<Duration> = None; |
||||
let mut custom_headers: Vec<(String, String)> = Vec::new(); |
||||
let mut opts_cookies: Vec<(String, String)> = Vec::new(); |
||||
// Body, with the Content-Type it implies (None for a raw body).
|
||||
let mut body: Option<(Vec<u8>, Option<&'static str>)> = None; |
||||
// Auth: (is_digest, username, password).
|
||||
let mut auth: Option<(bool, String, String)> = None; |
||||
|
||||
if let Some(opts) = opts.as_ref() { |
||||
if let Some(t) = opts.get::<Option<f64>>("timeout")? { |
||||
timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| { |
||||
mlua::Error::external( |
||||
"http: timeout must be a non-negative finite number of seconds", |
||||
) |
||||
})?); |
||||
} |
||||
if let Some(headers) = opts.get::<Option<LuaTable>>("headers")? { |
||||
for pair in headers.pairs::<String, String>() { |
||||
custom_headers.push(pair?); |
||||
} |
||||
} |
||||
if let Some(cookies) = opts.get::<Option<LuaTable>>("cookies")? { |
||||
for pair in cookies.pairs::<String, String>() { |
||||
opts_cookies.push(pair?); |
||||
} |
||||
} |
||||
|
||||
// Body: json > form > raw body (first one present wins).
|
||||
if let Some(json_val) = opts.get::<Option<LuaValue>>("json")? { |
||||
let json = lua_to_json(json_val, 0)?; |
||||
let s = serde_json::to_string(&json).map_err(mlua::Error::external)?; |
||||
body = Some((s.into_bytes(), Some("application/json"))); |
||||
} else if let Some(form) = opts.get::<Option<LuaTable>>("form")? { |
||||
let mut pairs = Vec::new(); |
||||
for pair in form.pairs::<String, String>() { |
||||
pairs.push(pair?); |
||||
} |
||||
body = Some(( |
||||
form_urlencode(&pairs).into_bytes(), |
||||
Some("application/x-www-form-urlencoded"), |
||||
)); |
||||
} else if let Some(b) = opts.get::<Option<mlua::String>>("body")? { |
||||
body = Some((b.as_bytes().to_vec(), None)); |
||||
} |
||||
|
||||
// Auth: { username, password, scheme = "basic" (default) | "digest" }.
|
||||
if let Some(auth_tbl) = opts.get::<Option<LuaTable>>("auth")? { |
||||
let username = auth_tbl |
||||
.get::<Option<String>>("username")? |
||||
.ok_or_else(|| mlua::Error::external("http: auth.username is required"))?; |
||||
let password = auth_tbl |
||||
.get::<Option<String>>("password")? |
||||
.ok_or_else(|| mlua::Error::external("http: auth.password is required"))?; |
||||
let is_digest = match auth_tbl.get::<Option<String>>("scheme")?.as_deref() { |
||||
None | Some("basic") => false, |
||||
Some("digest") => true, |
||||
Some(other) => { |
||||
return Err(mlua::Error::external(format!( |
||||
"http: auth.scheme must be \"basic\" or \"digest\", got \"{other}\"" |
||||
))); |
||||
} |
||||
}; |
||||
auth = Some((is_digest, username, password)); |
||||
} |
||||
} |
||||
|
||||
let is_digest = matches!(auth.as_ref(), Some((true, _, _))); |
||||
let mut digest_header: Option<String> = None; |
||||
let mut digest_tried = false; |
||||
|
||||
let mut url = url; |
||||
let mut redirects_left = MAX_REDIRECTS; |
||||
|
||||
let resp = loop { |
||||
// Host used for cookie matching and as the Set-Cookie domain fallback;
|
||||
// recomputed each hop since a redirect may cross hosts.
|
||||
let host = reqwest::Url::parse(&url) |
||||
.ok() |
||||
.and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())); |
||||
|
||||
let mut req = client.request(method.clone(), &url); |
||||
|
||||
if let Some(t) = timeout { |
||||
req = req.timeout(t); |
||||
} |
||||
for (k, v) in &custom_headers { |
||||
req = req.header(k, v); |
||||
} |
||||
|
||||
// Cookie header: jar cookies for this host, then per-request cookies
|
||||
// which override on name collision.
|
||||
let mut cookie_map: HashMap<String, String> = HashMap::new(); |
||||
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { |
||||
for (n, v) in jar.lock().unwrap().cookies_for(host) { |
||||
cookie_map.insert(n, v); |
||||
} |
||||
} |
||||
for (k, v) in &opts_cookies { |
||||
cookie_map.insert(k.clone(), v.clone()); |
||||
} |
||||
if !cookie_map.is_empty() { |
||||
let header = cookie_map |
||||
.iter() |
||||
.map(|(k, v)| format!("{k}={v}")) |
||||
.collect::<Vec<_>>() |
||||
.join("; "); |
||||
req = req.header(reqwest::header::COOKIE, header); |
||||
} |
||||
|
||||
if let Some((bytes, ct)) = body.as_ref() { |
||||
if let Some(ct) = ct { |
||||
req = req.header(reqwest::header::CONTENT_TYPE, *ct); |
||||
} |
||||
req = req.body(bytes.clone()); |
||||
} |
||||
|
||||
// Auth. Basic goes out on every hop; digest's Authorization is set only
|
||||
// after the 401 challenge below has been answered (digest_header).
|
||||
if let Some((digest, username, password)) = auth.as_ref() { |
||||
if *digest { |
||||
if let Some(h) = digest_header.as_ref() { |
||||
req = req.header(reqwest::header::AUTHORIZATION, h); |
||||
} |
||||
} else { |
||||
req = req.basic_auth(username, Some(password)); |
||||
} |
||||
} |
||||
|
||||
let resp = req |
||||
.send() |
||||
.await |
||||
.map_err(|e| mlua::Error::external(format!("http: {e}")))?; |
||||
let status = resp.status(); |
||||
|
||||
// Store Set-Cookie from this hop into the jar.
|
||||
if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { |
||||
let mut j = jar.lock().unwrap(); |
||||
for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() { |
||||
if let Ok(s) = value.to_str() { |
||||
j.set_from_header(host, s); |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Digest auth: answer a 401 challenge once, then retry the same request
|
||||
// with the computed Authorization header.
|
||||
if is_digest && !digest_tried && status == reqwest::StatusCode::UNAUTHORIZED { |
||||
let challenge = resp |
||||
.headers() |
||||
.get(reqwest::header::WWW_AUTHENTICATE) |
||||
.and_then(|v| v.to_str().ok()) |
||||
.map(str::to_string); |
||||
if let Some((_, username, password)) = auth.as_ref() |
||||
&& let Some(challenge) = challenge |
||||
&& let Ok(mut prompt) = digest_auth::parse(&challenge) |
||||
{ |
||||
let uri = reqwest::Url::parse(&url) |
||||
.map(|u| match u.query() { |
||||
Some(q) => format!("{}?{}", u.path(), q), |
||||
None => u.path().to_string(), |
||||
}) |
||||
.unwrap_or_else(|_| url.clone()); |
||||
let ctx = digest_auth::AuthContext::new_with_method( |
||||
username.as_str(), |
||||
password.as_str(), |
||||
uri, |
||||
body.as_ref().map(|(b, _)| b.as_slice()), |
||||
digest_auth::HttpMethod::from(method.as_str()), |
||||
); |
||||
if let Ok(answer) = prompt.respond(&ctx) { |
||||
digest_header = Some(answer.to_header_string()); |
||||
digest_tried = true; |
||||
continue; |
||||
} |
||||
} |
||||
} |
||||
|
||||
// Follow a redirect if there is one to follow.
|
||||
if status.is_redirection() |
||||
&& redirects_left > 0 |
||||
&& let Some(next) = resp |
||||
.headers() |
||||
.get(reqwest::header::LOCATION) |
||||
.and_then(|l| l.to_str().ok()) |
||||
.and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok()) |
||||
{ |
||||
redirects_left -= 1; |
||||
// 303, and 301/302 on a POST, degrade to a bodyless GET — the
|
||||
// behaviour browsers and reqwest's own redirect policy apply.
|
||||
let code = status.as_u16(); |
||||
if code == 303 || ((code == 301 || code == 302) && method == reqwest::Method::POST) { |
||||
method = reqwest::Method::GET; |
||||
body = None; |
||||
} |
||||
url = next.to_string(); |
||||
continue; |
||||
} |
||||
|
||||
break resp; |
||||
}; |
||||
|
||||
let status = resp.status().as_u16(); |
||||
|
||||
// Response headers: lowercase names, first value wins.
|
||||
let headers_tbl = lua.create_table()?; |
||||
for (name, value) in resp.headers().iter() { |
||||
let lname = name.as_str().to_ascii_lowercase(); |
||||
if !headers_tbl.contains_key(lname.as_str())? { |
||||
headers_tbl.raw_set(lname.as_str(), lua.create_string(value.as_bytes())?)?; |
||||
} |
||||
} |
||||
|
||||
let bytes = resp |
||||
.bytes() |
||||
.await |
||||
.map_err(|e| mlua::Error::external(format!("http: {e}")))?; |
||||
|
||||
let out = lua.create_table()?; |
||||
out.raw_set("status", status)?; |
||||
out.raw_set("ok", (200..=299).contains(&status))?; |
||||
out.raw_set("headers", headers_tbl)?; |
||||
out.raw_set("body", lua.create_string(&bytes)?)?; |
||||
Ok(out) |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build the plain Lua table that backs a session. State (the cookie jar) lives
|
||||
/// in the closures; the Lua layer applies a metatable for the method shorthands.
|
||||
/// Methods are called as `s:method(...)`, so each closure receives the session
|
||||
/// table as a leading `_this` argument that it ignores.
|
||||
fn make_session( |
||||
lua: &Lua, |
||||
client: reqwest::Client, |
||||
jar: Arc<Mutex<CookieJar>>, |
||||
) -> LuaResult<LuaTable> { |
||||
let tbl = lua.create_table()?; |
||||
|
||||
tbl.raw_set( |
||||
"_request", |
||||
lua.create_async_function({ |
||||
let client = client.clone(); |
||||
let jar = jar.clone(); |
||||
move |lua, (_this, method, url, opts): (LuaTable, String, String, Option<LuaTable>)| { |
||||
let client = client.clone(); |
||||
let jar = jar.clone(); |
||||
async move { |
||||
execute_request(lua, client, Some(jar), method, url, opts).await |
||||
} |
||||
} |
||||
})?, |
||||
)?; |
||||
|
||||
tbl.raw_set("save", { |
||||
let jar = jar.clone(); |
||||
lua.create_function(move |_, (_this, path): (LuaTable, String)| { |
||||
let jsonl = jar.lock().unwrap().to_jsonl(); |
||||
std::fs::write(&path, jsonl) |
||||
.map_err(|e| mlua::Error::external(format!("session:save: {e}"))) |
||||
})? |
||||
})?; |
||||
|
||||
tbl.raw_set("load", { |
||||
let jar = jar.clone(); |
||||
lua.create_function(move |_, (_this, path): (LuaTable, String)| { |
||||
let src = std::fs::read_to_string(&path) |
||||
.map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; |
||||
jar.lock().unwrap().merge_jsonl(&src); |
||||
Ok(()) |
||||
})? |
||||
})?; |
||||
|
||||
tbl.raw_set("clearCookies", { |
||||
let jar = jar.clone(); |
||||
lua.create_function(move |_, _this: LuaTable| { |
||||
jar.lock().unwrap().cookies.clear(); |
||||
Ok(()) |
||||
})? |
||||
})?; |
||||
|
||||
tbl.raw_set("cookies", { |
||||
let jar = jar.clone(); |
||||
lua.create_function(move |lua, _this: LuaTable| { |
||||
let outer = lua.create_table()?; |
||||
let j = jar.lock().unwrap(); |
||||
for (domain, names) in j.cookies.iter() { |
||||
let inner = lua.create_table()?; |
||||
for (name, value) in names.iter() { |
||||
inner.raw_set(name.as_str(), value.as_str())?; |
||||
} |
||||
outer.raw_set(domain.as_str(), inner)?; |
||||
} |
||||
Ok(outer) |
||||
})? |
||||
})?; |
||||
|
||||
Ok(tbl) |
||||
} |
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn install(lua: &Lua) -> LuaResult<()> { |
||||
// reqwest is built with `rustls-no-provider`, so it has no crypto provider
|
||||
// of its own and panics ("No provider set") unless one is installed as the
|
||||
// process default before the client is built. `ring` is self-contained
|
||||
// (compiled in, no system OpenSSL). Idempotent across Lua states — only the
|
||||
// first install in the process wins, and they would all install ring.
|
||||
let _ = rustls::crypto::ring::default_provider().install_default(); |
||||
|
||||
let client = reqwest::Client::builder() |
||||
.timeout(Duration::from_secs(30)) |
||||
// reqwest sends no User-Agent by default; some edges/CDNs reject
|
||||
// empty-UA requests outright. A per-request `headers` entry overrides it.
|
||||
.user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) |
||||
// Redirects are followed manually in execute_request so Set-Cookie
|
||||
// headers on 30x responses can be captured into the cookie jar.
|
||||
.redirect(reqwest::redirect::Policy::none()) |
||||
.build() |
||||
.map_err(mlua::Error::external)?; |
||||
|
||||
let http = lua.create_table()?; |
||||
|
||||
// Stateless http.request — no cookie jar.
|
||||
http.raw_set( |
||||
"request", |
||||
lua.create_async_function({ |
||||
let client = client.clone(); |
||||
move |lua, (method, url, opts): (String, String, Option<LuaTable>)| { |
||||
let client = client.clone(); |
||||
async move { execute_request(lua, client, None, method, url, opts).await } |
||||
} |
||||
})?, |
||||
)?; |
||||
|
||||
// http.session(path?) — constructor returning the raw session table.
|
||||
http.raw_set( |
||||
"session", |
||||
lua.create_async_function({ |
||||
let client = client.clone(); |
||||
move |lua, path: Option<String>| { |
||||
let client = client.clone(); |
||||
async move { |
||||
let mut jar = CookieJar::default(); |
||||
if let Some(p) = path { |
||||
let src = tokio::fs::read_to_string(&p) |
||||
.await |
||||
.map_err(|e| mlua::Error::external(format!("http.session: {e}")))?; |
||||
jar.merge_jsonl(&src); |
||||
} |
||||
make_session(&lua, client, Arc::new(Mutex::new(jar))) |
||||
} |
||||
} |
||||
})?, |
||||
)?; |
||||
|
||||
lua.globals().raw_set("http", http)?; |
||||
|
||||
// Lua side adds: resp.json(), method shorthands, getJSON/postJSON, and the
|
||||
// session metatable wrapping http.session().
|
||||
lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec() |
||||
} |
||||
Loading…
Reference in new issue