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:
831
Cargo.lock
generated
831
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ lsp-types = "0.94"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
91
examples/http.lux
Normal file
91
examples/http.lux
Normal file
@@ -0,0 +1,91 @@
|
||||
// HTTP example - demonstrates the Http effect
|
||||
//
|
||||
// This script makes HTTP requests and parses JSON responses
|
||||
|
||||
fn main(): Unit with {Console, Http} = {
|
||||
Console.print("=== Lux HTTP Example ===")
|
||||
Console.print("")
|
||||
|
||||
// Make a GET request to a public API
|
||||
Console.print("Fetching data from httpbin.org...")
|
||||
Console.print("")
|
||||
|
||||
match Http.get("https://httpbin.org/get") {
|
||||
Ok(response) => {
|
||||
Console.print("GET request successful!")
|
||||
Console.print(" Status: " + toString(response.status))
|
||||
Console.print(" Body length: " + toString(String.length(response.body)) + " bytes")
|
||||
Console.print("")
|
||||
|
||||
// Parse the JSON response
|
||||
match Json.parse(response.body) {
|
||||
Ok(json) => {
|
||||
Console.print("Parsed JSON response:")
|
||||
match Json.get(json, "origin") {
|
||||
Some(origin) => match Json.asString(origin) {
|
||||
Some(ip) => Console.print(" Your IP: " + ip),
|
||||
None => Console.print(" origin: (not a string)")
|
||||
},
|
||||
None => Console.print(" origin: (not found)")
|
||||
}
|
||||
match Json.get(json, "url") {
|
||||
Some(url) => match Json.asString(url) {
|
||||
Some(u) => Console.print(" URL: " + u),
|
||||
None => Console.print(" url: (not a string)")
|
||||
},
|
||||
None => Console.print(" url: (not found)")
|
||||
}
|
||||
},
|
||||
Err(e) => Console.print("JSON parse error: " + e)
|
||||
}
|
||||
},
|
||||
Err(e) => Console.print("GET request failed: " + e)
|
||||
}
|
||||
|
||||
Console.print("")
|
||||
Console.print("--- POST Request ---")
|
||||
Console.print("")
|
||||
|
||||
// Make a POST request with JSON body
|
||||
let requestBody = Json.object([("message", Json.string("Hello from Lux!")), ("version", Json.int(1))])
|
||||
Console.print("Sending POST with JSON body...")
|
||||
Console.print(" Body: " + Json.stringify(requestBody))
|
||||
Console.print("")
|
||||
|
||||
match Http.postJson("https://httpbin.org/post", requestBody) {
|
||||
Ok(response) => {
|
||||
Console.print("POST request successful!")
|
||||
Console.print(" Status: " + toString(response.status))
|
||||
|
||||
// Parse and extract what we sent
|
||||
match Json.parse(response.body) {
|
||||
Ok(json) => match Json.get(json, "json") {
|
||||
Some(sentJson) => {
|
||||
Console.print(" Server received:")
|
||||
Console.print(" " + Json.stringify(sentJson))
|
||||
},
|
||||
None => Console.print(" (no json field in response)")
|
||||
},
|
||||
Err(e) => Console.print("JSON parse error: " + e)
|
||||
}
|
||||
},
|
||||
Err(e) => Console.print("POST request failed: " + e)
|
||||
}
|
||||
|
||||
Console.print("")
|
||||
Console.print("--- Headers ---")
|
||||
Console.print("")
|
||||
|
||||
// Show response headers
|
||||
match Http.get("https://httpbin.org/headers") {
|
||||
Ok(response) => {
|
||||
Console.print("Response headers (first 5):")
|
||||
let count = 0
|
||||
// Note: Can't easily iterate with effects in callbacks, so just show count
|
||||
Console.print(" Total headers: " + toString(List.length(response.headers)))
|
||||
},
|
||||
Err(e) => Console.print("Request failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
let result = run main() with {}
|
||||
@@ -49,6 +49,9 @@
|
||||
version = "0.1.0";
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
|
||||
nativeBuildInputs = [ pkgs.pkg-config ];
|
||||
buildInputs = [ pkgs.openssl ];
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
78
src/types.rs
78
src/types.rs
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user