diff --git a/.lune/hello_lune.luau b/.lune/hello_lune.luau index 946690a..01a04a6 100644 --- a/.lune/hello_lune.luau +++ b/.lune/hello_lune.luau @@ -110,9 +110,9 @@ local result = process.spawn("ping", { Using the result of a spawned process, exiting the process - We use the result from the above ping command and parse it - to show the results it gave us in a nicer format, then we - either exit successfully or with an error (exit code 1) + We use the result from the above ping command and parse + it to show the results it gave us in a nicer format, here we + also exit with an error (exit code 1) if spawning the process failed ]==] if result.ok then @@ -126,8 +126,52 @@ if result.ok then print(string.format("Average ping time: %.3fms", assert(tonumber(avg)))) print(string.format("Standard deviation: %.3fms", assert(tonumber(stddev)))) else + print("\nFailed to send ping to google!") print(result.stderr) process.exit(result.code) end +--[==[ + EXAMPLE #7 + + Using the built-in networking library +]==] +print("\nSending PATCH request to web API 📤") +local apiResult = net.request({ + url = "https://jsonplaceholder.typicode.com/posts/1", + method = "PATCH", + headers = { + ["Content-Type"] = "application/json", + }, + body = net.jsonEncode({ + title = "foo", + body = "bar", + }), +}) + +if not result.ok then + print("\nFailed to send network request!") + print(string.format("%d (%s)", apiResult.statusCode, apiResult.statusMessage)) + print(apiResult.body) + process.exit(1) +end + +type ApiResponse = { + id: number, + title: string, + body: string, + userId: number, +} + +local apiResponse: ApiResponse = net.jsonDecode(apiResult.body) +assert(apiResponse.title == "foo", "Invalid json response") +assert(apiResponse.body == "bar", "Invalid json response") +print("Got valid JSON response with changes applied") + +--[==[ + Example #8 + + Saying goodbye 😔 +]==] + print("\nGoodbye, lune! 🌙") diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ed1e39..8ae7d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Added networking functions under `net` + + Example usage: + + ```lua + local apiResult = net.request({ + url = "https://jsonplaceholder.typicode.com/posts/1", + method = "PATCH", + headers = { + ["Content-Type"] = "application/json", + }, + body = net.jsonEncode({ + title = "foo", + body = "bar", + }), + }) + + local apiResponse = net.jsonDecode(apiResult.body) + assert(apiResponse.title == "foo", "Invalid json response") + assert(apiResponse.body == "bar", "Invalid json response") + ``` + +### Changed + +- The `json` api is now part of `net` + - `json.encode` becomes `net.jsonEncode` + - `json.decode` become `net.jsonDecode` + +### Fixed + +- Fixed JSON decode not working properly + ## `0.0.2` - January 19th, 2023 ### Added diff --git a/README.md b/README.md index 066b27f..889598d 100644 --- a/README.md +++ b/README.md @@ -47,12 +47,24 @@ type fs = { } ``` -### **`json`** - JSON +### **`net`** - Networking ```lua -type json = { - encode: (value: any, pretty: boolean?) -> string, - decode: (encoded: string) -> any, +type net = { + request: (config: string | { + url: string, + method: ("GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH")?, + headers: { [string]: string }?, + body: string?, + }) -> { + ok: boolean, + statusCode: number, + statusMessage: string, + headers: { [string]: string }, + body: string, + }, + jsonEncode: (value: any, pretty: boolean?) -> string, + jsonDecode: (encoded: string) -> any, } ``` diff --git a/lune.yml b/lune.yml index f749280..97698ce 100644 --- a/lune.yml +++ b/lune.yml @@ -27,15 +27,18 @@ globals: fs.isDir: args: - type: string - # JSON - json.encode: + # Net (networking) + net.jsonEncode: args: - type: any - required: false type: boolean - json.decode: + net.jsonDecode: args: - type: string + net.request: + args: + - type: any # Process process.getEnvVars: process.getEnvVar: diff --git a/luneTypes.d.luau b/luneTypes.d.luau index 524de27..611bc98 100644 --- a/luneTypes.d.luau +++ b/luneTypes.d.luau @@ -11,9 +11,21 @@ declare fs: { isDir: (path: string) -> boolean, } -declare json: { - encode: (value: any, pretty: boolean?) -> string, - decode: (encoded: string) -> any, +declare net: { + request: (config: string | { + url: string, + method: ("GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH")?, + headers: { [string]: string }?, + body: string?, + }) -> { + ok: boolean, + statusCode: number, + statusMessage: string, + headers: { [string]: string }, + body: string, + }, + jsonEncode: (value: any, pretty: boolean?) -> string, + jsonDecode: (encoded: string) -> any, } declare process: { diff --git a/src/cli.rs b/src/cli.rs index 257a14a..6875925 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -7,7 +7,7 @@ use clap::{CommandFactory, Parser}; use mlua::{Lua, MultiValue, Result, ToLua}; use crate::{ - lune::{fs::LuneFs, json::LuneJson, process::LuneProcess}, + lune::{fs::LuneFs, net::LuneNet, process::LuneProcess}, utils::GithubClient, }; @@ -102,8 +102,8 @@ impl Cli { let lua = Lua::new(); let globals = lua.globals(); globals.set("fs", LuneFs::new())?; + globals.set("net", LuneNet::new())?; globals.set("process", LuneProcess::new())?; - globals.set("json", LuneJson::new())?; lua.sandbox(true)?; // Load & call the file with the given args let lua_args = self diff --git a/src/lune/json.rs b/src/lune/json.rs deleted file mode 100644 index d24b527..0000000 --- a/src/lune/json.rs +++ /dev/null @@ -1,28 +0,0 @@ -use mlua::{Error, Lua, LuaSerdeExt, Result, UserData, UserDataMethods, Value}; - -pub struct LuneJson(); - -impl LuneJson { - pub fn new() -> Self { - Self() - } -} - -impl UserData for LuneJson { - fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) { - methods.add_function("encode", json_encode); - methods.add_function("decode", json_decode); - } -} - -fn json_encode(_: &Lua, (val, pretty): (Value, Option)) -> Result { - if let Some(true) = pretty { - Ok(serde_json::to_string_pretty(&val).map_err(Error::external)?) - } else { - Ok(serde_json::to_string(&val).map_err(Error::external)?) - } -} - -fn json_decode(lua: &Lua, json: String) -> Result { - lua.to_value(&json) -} diff --git a/src/lune/mod.rs b/src/lune/mod.rs index e04e1b9..653afb6 100644 --- a/src/lune/mod.rs +++ b/src/lune/mod.rs @@ -1,3 +1,3 @@ pub mod fs; -pub mod json; +pub mod net; pub mod process; diff --git a/src/lune/net.rs b/src/lune/net.rs new file mode 100644 index 0000000..5257b29 --- /dev/null +++ b/src/lune/net.rs @@ -0,0 +1,137 @@ +use std::{collections::HashMap, str::FromStr}; + +use mlua::{Error, Lua, LuaSerdeExt, Result, UserData, UserDataMethods, Value}; +use reqwest::{ + header::{HeaderMap, HeaderName, HeaderValue}, + Method, +}; + +use crate::utils::get_github_user_agent_header; + +pub struct LuneNet(); + +impl LuneNet { + pub fn new() -> Self { + Self() + } +} + +impl UserData for LuneNet { + fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) { + methods.add_function("jsonEncode", net_json_encode); + methods.add_function("jsonDecode", net_json_decode); + methods.add_async_function("request", net_request); + } +} + +fn net_json_encode(_: &Lua, (val, pretty): (Value, Option)) -> Result { + if let Some(true) = pretty { + serde_json::to_string_pretty(&val).map_err(Error::external) + } else { + serde_json::to_string(&val).map_err(Error::external) + } +} + +fn net_json_decode(lua: &Lua, json: String) -> Result { + let json: serde_json::Value = serde_json::from_str(&json).map_err(Error::external)?; + lua.to_value(&json) +} + +async fn net_request<'lua>(lua: &'lua Lua, config: Value<'lua>) -> Result> { + // Extract stuff from config and make sure its all valid + let (url, method, headers, body) = match config { + Value::String(s) => { + let url = s.to_string_lossy().to_string(); + let method = "GET".to_string(); + (url, method, None, None) + } + Value::Table(tab) => { + // Extract url + let url = match tab.raw_get::<&str, mlua::String>("url") { + Ok(config_url) => config_url.to_string_lossy().to_string(), + Err(_) => return Err(Error::RuntimeError("Missing 'url' in config".to_string())), + }; + // Extract method + let method = match tab.raw_get::<&str, mlua::String>("method") { + Ok(config_method) => config_method.to_string_lossy().trim().to_ascii_uppercase(), + Err(_) => "GET".to_string(), + }; + // Extract headers + let headers = match tab.raw_get::<&str, mlua::Table>("headers") { + Ok(config_headers) => { + let mut lua_headers = HeaderMap::new(); + for pair in config_headers.pairs::() { + let (key, value) = pair?; + lua_headers.insert( + HeaderName::from_str(key.to_str()?).map_err(Error::external)?, + HeaderValue::from_str(value.to_str()?).map_err(Error::external)?, + ); + } + Some(lua_headers) + } + Err(_) => None, + }; + // Extract body + let body = match tab.raw_get::<&str, mlua::String>("body") { + Ok(config_body) => Some(config_body.as_bytes().to_owned()), + Err(_) => None, + }; + (url, method, headers, body) + } + _ => return Err(Error::RuntimeError("Invalid config value".to_string())), + }; + // Convert method string into proper enum + let method = match Method::from_str(&method) { + Ok(meth) => meth, + Err(_) => { + return Err(Error::RuntimeError(format!( + "Invalid config method '{}'", + &method + ))) + } + }; + // Extract headers from config, force user agent + let mut header_map = if let Some(headers) = headers { + headers + } else { + HeaderMap::new() + }; + header_map.insert( + "User-Agent", + HeaderValue::from_str(&get_github_user_agent_header()).map_err(Error::external)?, + ); + // Create a client to send a request with + // FUTURE: Try to reuse this client + let client = reqwest::Client::builder() + .build() + .map_err(Error::external)?; + // Create and send the request + let mut request = client.request(method, url).headers(header_map); + if let Some(body) = body { + request = request.body(body) + } + let response = request.send().await.map_err(Error::external)?; + // Extract status, headers, body + let res_status = response.status(); + let res_headers = response.headers().to_owned(); + let res_bytes = response.bytes().await.map_err(Error::external)?; + // Construct and return a readonly lua table with results + let tab = lua.create_table()?; + tab.raw_set("ok", res_status.is_success())?; + tab.raw_set("statusCode", res_status.as_u16())?; + tab.raw_set( + "statusMessage", + res_status.canonical_reason().unwrap_or("?"), + )?; + tab.raw_set( + "headers", + res_headers + .iter() + .filter(|(_, value)| value.to_str().is_ok()) + .map(|(key, value)| (key.as_str(), value.to_str().unwrap())) + .collect::>(), + )?; + tab.raw_set("body", lua.create_string(&res_bytes)?)?; + tab.set_readonly(true); + Ok(Value::Table(tab)) +} diff --git a/src/utils.rs b/src/utils.rs index bab3854..6ac1dd3 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -37,15 +37,11 @@ pub struct GithubClient { impl GithubClient { pub fn new() -> Result { - let (github_owner, github_repo) = env!("CARGO_PKG_REPOSITORY") - .strip_prefix("https://github.com/") - .unwrap() - .split_once('/') - .unwrap(); + let (github_owner, github_repo) = get_github_owner_and_repo(); let mut headers = HeaderMap::new(); headers.insert( "User-Agent", - HeaderValue::from_str(&format!("{}-{}-cli", github_owner, github_repo))?, + HeaderValue::from_str(&get_github_user_agent_header())?, ); headers.insert( "Accept", @@ -58,8 +54,8 @@ impl GithubClient { let client = Client::builder().default_headers(headers).build()?; Ok(Self { client, - github_owner: github_owner.to_string(), - github_repo: github_repo.to_string(), + github_owner, + github_repo, }) } @@ -128,6 +124,20 @@ impl GithubClient { } } +pub fn get_github_owner_and_repo() -> (String, String) { + let (github_owner, github_repo) = env!("CARGO_PKG_REPOSITORY") + .strip_prefix("https://github.com/") + .unwrap() + .split_once('/') + .unwrap(); + (github_owner.to_owned(), github_repo.to_owned()) +} + +pub fn get_github_user_agent_header() -> String { + let (github_owner, github_repo) = get_github_owner_and_repo(); + format!("{}-{}-cli", github_owner, github_repo) +} + pub fn pretty_print_luau_error(e: &mlua::Error) { match e { mlua::Error::RuntimeError(e) => {