feat: add HTTP client support

Add Http effect for making HTTP requests:
- Http.get(url) - GET request
- Http.post(url, body) - POST with string body
- Http.postJson(url, json) - POST with JSON body
- Http.put(url, body) - PUT request
- Http.delete(url) - DELETE request

Returns Result<HttpResponse, String> where HttpResponse contains
status code, body, and headers.

Includes reqwest dependency with blocking client, OpenSSL support
in flake.nix, and example at examples/http.lux demonstrating
API requests with JSON parsing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 16:30:48 -05:00
parent ef9746c2fe
commit 296686de17
7 changed files with 1168 additions and 14 deletions

View File

@@ -3206,6 +3206,68 @@ impl Interpreter {
}
}
// ===== Http Effect =====
("Http", "get") => {
let url = match request.args.first() {
Some(Value::String(s)) => s.clone(),
_ => return Err(RuntimeError {
message: "Http.get requires a URL string".to_string(),
span: None,
}),
};
self.http_request("GET", &url, None)
}
("Http", "post") => {
let (url, body) = match (request.args.get(0), request.args.get(1)) {
(Some(Value::String(u)), Some(Value::String(b))) => (u.clone(), b.clone()),
_ => return Err(RuntimeError {
message: "Http.post requires URL and body strings".to_string(),
span: None,
}),
};
self.http_request("POST", &url, Some(&body))
}
("Http", "postJson") => {
let (url, json) = match (request.args.get(0), request.args.get(1)) {
(Some(Value::String(u)), Some(Value::Json(j))) => (u.clone(), j.clone()),
_ => return Err(RuntimeError {
message: "Http.postJson requires URL string and Json value".to_string(),
span: None,
}),
};
self.http_request_json("POST", &url, &json)
}
("Http", "put") => {
let (url, body) = match (request.args.get(0), request.args.get(1)) {
(Some(Value::String(u)), Some(Value::String(b))) => (u.clone(), b.clone()),
_ => return Err(RuntimeError {
message: "Http.put requires URL and body strings".to_string(),
span: None,
}),
};
self.http_request("PUT", &url, Some(&body))
}
("Http", "delete") => {
let url = match request.args.first() {
Some(Value::String(s)) => s.clone(),
_ => return Err(RuntimeError {
message: "Http.delete requires a URL string".to_string(),
span: None,
}),
};
self.http_request("DELETE", &url, None)
}
("Http", "setHeader") => {
// Headers would need to be stored in interpreter state
// For now, this is a no-op placeholder
Ok(Value::Unit)
}
("Http", "setTimeout") => {
// Timeout would need to be stored in interpreter state
// For now, this is a no-op placeholder
Ok(Value::Unit)
}
_ => Err(RuntimeError {
message: format!(
"Unhandled effect operation: {}.{}",
@@ -3215,6 +3277,118 @@ impl Interpreter {
}),
}
}
/// Helper for HTTP requests
fn http_request(&self, method: &str, url: &str, body: Option<&str>) -> Result<Value, RuntimeError> {
use reqwest::blocking::Client;
let client = Client::new();
let request = match method {
"GET" => client.get(url),
"POST" => {
let req = client.post(url);
if let Some(b) = body {
req.header("Content-Type", "text/plain").body(b.to_string())
} else {
req
}
}
"PUT" => {
let req = client.put(url);
if let Some(b) = body {
req.header("Content-Type", "text/plain").body(b.to_string())
} else {
req
}
}
"DELETE" => client.delete(url),
_ => return Err(RuntimeError {
message: format!("Unsupported HTTP method: {}", method),
span: None,
}),
};
match request.send() {
Ok(response) => {
let status = response.status().as_u16() as i64;
let headers: Vec<Value> = response.headers()
.iter()
.map(|(name, value)| {
Value::Tuple(vec![
Value::String(name.to_string()),
Value::String(value.to_str().unwrap_or("").to_string()),
])
})
.collect();
let body = response.text().unwrap_or_default();
let response_record = Value::Record(HashMap::from([
("status".to_string(), Value::Int(status)),
("body".to_string(), Value::String(body)),
("headers".to_string(), Value::List(headers)),
]));
Ok(Value::Constructor {
name: "Ok".to_string(),
fields: vec![response_record],
})
}
Err(e) => {
Ok(Value::Constructor {
name: "Err".to_string(),
fields: vec![Value::String(e.to_string())],
})
}
}
}
/// Helper for HTTP requests with JSON body
fn http_request_json(&self, method: &str, url: &str, json: &serde_json::Value) -> Result<Value, RuntimeError> {
use reqwest::blocking::Client;
let client = Client::new();
let request = match method {
"POST" => client.post(url).json(json),
"PUT" => client.put(url).json(json),
_ => return Err(RuntimeError {
message: format!("Unsupported HTTP method for JSON: {}", method),
span: None,
}),
};
match request.send() {
Ok(response) => {
let status = response.status().as_u16() as i64;
let headers: Vec<Value> = response.headers()
.iter()
.map(|(name, value)| {
Value::Tuple(vec![
Value::String(name.to_string()),
Value::String(value.to_str().unwrap_or("").to_string()),
])
})
.collect();
let body = response.text().unwrap_or_default();
let response_record = Value::Record(HashMap::from([
("status".to_string(), Value::Int(status)),
("body".to_string(), Value::String(body)),
("headers".to_string(), Value::List(headers)),
]));
Ok(Value::Constructor {
name: "Ok".to_string(),
fields: vec![response_record],
})
}
Err(e) => {
Ok(Value::Constructor {
name: "Err".to_string(),
fields: vec![Value::String(e.to_string())],
})
}
}
}
}
impl Default for Interpreter {

View File

@@ -935,7 +935,7 @@ impl TypeChecker {
}
// Built-in effects are always available
let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process"];
let builtin_effects = ["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http"];
let is_builtin = builtin_effects.contains(&effect.name.as_str());
// Track this effect for inference
@@ -1440,7 +1440,7 @@ impl TypeChecker {
// Built-in effects are always available in run blocks (they have runtime implementations)
let builtin_effects: EffectSet =
EffectSet::from_iter(["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process"].iter().map(|s| s.to_string()));
EffectSet::from_iter(["Console", "Fail", "State", "Reader", "Random", "Time", "File", "Process", "Http"].iter().map(|s| s.to_string()));
// Extend current effects with handled ones and built-in effects
let combined = self.current_effects.union(&handled_effects).union(&builtin_effects);

View File

@@ -986,6 +986,84 @@ impl TypeEnv {
},
);
// Add Http effect
let http_response_type = Type::Record(vec![
("status".to_string(), Type::Int),
("body".to_string(), Type::String),
("headers".to_string(), Type::List(Box::new(Type::Tuple(vec![Type::String, Type::String])))),
]);
env.effects.insert(
"Http".to_string(),
EffectDef {
name: "Http".to_string(),
type_params: Vec::new(),
operations: vec![
EffectOpDef {
name: "get".to_string(),
params: vec![("url".to_string(), Type::String)],
return_type: Type::App {
constructor: Box::new(Type::Named("Result".to_string())),
args: vec![http_response_type.clone(), Type::String],
},
},
EffectOpDef {
name: "post".to_string(),
params: vec![
("url".to_string(), Type::String),
("body".to_string(), Type::String),
],
return_type: Type::App {
constructor: Box::new(Type::Named("Result".to_string())),
args: vec![http_response_type.clone(), Type::String],
},
},
EffectOpDef {
name: "postJson".to_string(),
params: vec![
("url".to_string(), Type::String),
("json".to_string(), Type::Named("Json".to_string())),
],
return_type: Type::App {
constructor: Box::new(Type::Named("Result".to_string())),
args: vec![http_response_type.clone(), Type::String],
},
},
EffectOpDef {
name: "put".to_string(),
params: vec![
("url".to_string(), Type::String),
("body".to_string(), Type::String),
],
return_type: Type::App {
constructor: Box::new(Type::Named("Result".to_string())),
args: vec![http_response_type.clone(), Type::String],
},
},
EffectOpDef {
name: "delete".to_string(),
params: vec![("url".to_string(), Type::String)],
return_type: Type::App {
constructor: Box::new(Type::Named("Result".to_string())),
args: vec![http_response_type.clone(), Type::String],
},
},
EffectOpDef {
name: "setHeader".to_string(),
params: vec![
("name".to_string(), Type::String),
("value".to_string(), Type::String),
],
return_type: Type::Unit,
},
EffectOpDef {
name: "setTimeout".to_string(),
params: vec![("seconds".to_string(), Type::Int)],
return_type: Type::Unit,
},
],
},
);
// Add Some and Ok, Err constructors
// Some : fn(a) -> Option<a>
let a = Type::var();