diff --git a/Cargo.toml b/Cargo.toml index c0cefa2..78cd825 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rust-utils" -version = "0.1.0" +version = "0.2.0" edition = "2021" [lib] @@ -13,19 +13,24 @@ lto = true debug = true [dependencies] +base64 = { version = "0.22.1", optional = true } +flume = { version = "0.11", optional = true } +once_cell = { version = "1.19", optional = true } +regex = { version = "1.10.4", optional = true } +regex-cache = { version = "0.2.1", optional = true } +serde = { version = "1.0", optional = true, features = ["derive"] } +serde_json = { version = "1.0", optional = true } thiserror = "1.0.6" -translit = {version = "0.5.0", optional = true } -regex = {version = "1.10.4", optional = true } -regex-cache = {version = "0.2.1", optional = true } -base64 = {version = "0.22.1", optional = true} +translit = { version = "0.5.0", optional = true } +ureq = { version = "2.10.0", optional = true } [features] -default = [ - "transliteration", - "regexp", - "file" -] +default = ["transliteration", "regexp", "file", "http"] -transliteration = ["translit"] -regexp = ["regex", "regex-cache"] file = ["base64"] +http = ["jobs", "once_cell", "serde", "serde_json", "ureq"] +regexp = ["regex", "regex-cache"] +transliteration = ["translit"] + +# internal feature-like things +jobs = ["flume"] diff --git a/dmsrc/http.dm b/dmsrc/http.dm new file mode 100644 index 0000000..f956782 --- /dev/null +++ b/dmsrc/http.dm @@ -0,0 +1,13 @@ +// HTTP Operations // + +#define RUSTUTILS_HTTP_METHOD_GET "get" +#define RUSTUTILS_HTTP_METHOD_PUT "put" +#define RUSTUTILS_HTTP_METHOD_DELETE "delete" +#define RUSTUTILS_HTTP_METHOD_PATCH "patch" +#define RUSTUTILS_HTTP_METHOD_HEAD "head" +#define RUSTUTILS_HTTP_METHOD_POST "post" +#define rustutils_http_request_blocking(method, url, body, headers, options) CALL_LIB(RUST_UTILS, "http_request_blocking")(method, url, body, headers, options) +#define rustutils_http_request_async(method, url, body, headers, options) CALL_LIB(RUST_UTILS, "http_request_async")(method, url, body, headers, options) +#define rustutils_http_check_request(req_id) CALL_LIB(RUST_UTILS, "http_check_request")(req_id) +/proc/rustutils_create_async_http_client() return CALL_LIB(RUST_UTILS, "start_http_client")() +/proc/rustutils_close_async_http_client() return CALL_LIB(RUST_UTILS, "shutdown_http_client")() diff --git a/src/error.rs b/src/error.rs index 8e59ad4..f818095 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ use std::{io, result, str::Utf8Error}; use thiserror::Error; -pub type BResult = result::Result; +pub type Result = result::Result; #[derive(Error, Debug)] pub enum Error { @@ -15,6 +15,12 @@ pub enum Error { Io(#[from] io::Error), #[error("Invalid algorithm specified.")] InvalidAlgorithm, + #[cfg(feature = "http")] + #[error(transparent)] + JsonSerialization(#[from] serde_json::Error), + #[cfg(feature = "http")] + #[error(transparent)] + Request(#[from] Box), } impl From for Error { diff --git a/src/file.rs b/src/file.rs index e833443..43c4b14 100644 --- a/src/file.rs +++ b/src/file.rs @@ -1,4 +1,4 @@ -use crate::error::BResult; +use crate::error::Result; use base64::Engine; use std::{ fs::File, @@ -12,7 +12,7 @@ byond_fn!(fn file_write(data, path, ...rest) { write(data, path, should_decode_b64).err() }); -fn write(data: &str, path: &str, base64decode: bool) -> BResult { +fn write(data: &str, path: &str, base64decode: bool) -> Result { let path: &std::path::Path = path.as_ref(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..bfbd3b7 --- /dev/null +++ b/src/http.rs @@ -0,0 +1,182 @@ +use crate::{error::Result, jobs}; +use serde::{Deserialize, Serialize}; +use std::cell::RefCell; +use std::collections::{BTreeMap, HashMap}; +use std::io::Write; + +// ---------------------------------------------------------------------------- +// Interface + +#[derive(Deserialize)] +struct RequestOptions { + #[serde(default)] + output_filename: Option, + #[serde(default)] + body_filename: Option, +} + +#[derive(Serialize)] +struct Response<'a> { + status_code: u16, + headers: HashMap, + body: Option<&'a str>, +} + +// If the response can be deserialized -> success. +// If the response can't be deserialized -> failure. +byond_fn!(fn http_request_blocking(method, url, body, headers, options) { + let req = match construct_request(method, url, body, headers, options) { + Ok(r) => r, + Err(e) => return Some(e.to_string()) + }; + + match submit_request(req) { + Ok(r) => Some(r), + Err(e) => Some(e.to_string()) + } +}); + +// Returns new job-id. +byond_fn!(fn http_request_async(method, url, body, headers, options) { + let req = match construct_request(method, url, body, headers, options) { + Ok(r) => r, + Err(e) => return Some(e.to_string()) + }; + + Some(jobs::start(move || { + match submit_request(req) { + Ok(r) => r, + Err(e) => e.to_string() + } + })) +}); + +// If the response can be deserialized -> success. +// If the response can't be deserialized -> failure or WIP. +byond_fn!(fn http_check_request(id) { + Some(jobs::check(id)) +}); + +// ---------------------------------------------------------------------------- +// Shared HTTP client state + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const PKG_NAME: &str = env!("CARGO_PKG_NAME"); + +thread_local! { + pub static HTTP_CLIENT: RefCell> = RefCell::new(Some(ureq::agent())); +} + +// ---------------------------------------------------------------------------- +// Request construction and execution + +struct RequestPrep { + req: ureq::Request, + output_filename: Option, + body: Vec, +} + +fn construct_request( + method: &str, + url: &str, + body: &str, + headers: &str, + options: &str, +) -> Result { + HTTP_CLIENT.with(|cell| { + let borrow = cell.borrow_mut(); + match &*borrow { + Some(client) => { + let mut req = match method { + "post" => client.post(url), + "put" => client.put(url), + "patch" => client.patch(url), + "delete" => client.delete(url), + "head" => client.head(url), + _ => client.get(url), + } + .set("User-Agent", &format!("{PKG_NAME}/{VERSION}")); + + let mut final_body = body.as_bytes().to_vec(); + + if !headers.is_empty() { + let headers: BTreeMap<&str, &str> = serde_json::from_str(headers)?; + for (key, value) in headers { + req = req.set(key, value); + } + } + + let mut output_filename = None; + if !options.is_empty() { + let options: RequestOptions = serde_json::from_str(options)?; + output_filename = options.output_filename; + if let Some(fname) = options.body_filename { + final_body = std::fs::read(fname)?; + } + } + + Ok(RequestPrep { + req, + output_filename, + body: final_body, + }) + } + + // If we got here we royally fucked up + None => { + let client = ureq::agent(); + let req = client.get(""); + let output_filename = None; + Ok(RequestPrep { + req, + output_filename, + body: Vec::new(), + }) + } + } + }) +} + +fn submit_request(prep: RequestPrep) -> Result { + let response = prep.req.send_bytes(&prep.body).map_err(Box::new)?; + + let body; + let mut resp = Response { + status_code: response.status(), + headers: HashMap::new(), + body: None, + }; + + for key in response.headers_names() { + let Some(value) = response.header(&key) else { + continue; + }; + + resp.headers.insert(key, value.to_owned()); + } + + if let Some(output_filename) = prep.output_filename { + let mut writer = std::io::BufWriter::new(std::fs::File::create(output_filename)?); + std::io::copy(&mut response.into_reader(), &mut writer)?; + writer.flush()?; + } else { + body = response.into_string()?; + resp.body = Some(&body); + } + + Ok(serde_json::to_string(&resp)?) +} + +byond_fn!( + fn start_http_client() { + HTTP_CLIENT.with(|cell| cell.replace(Some(ureq::agent()))); + Some("") + } +); + +byond_fn!( + fn shutdown_http_client() { + HTTP_CLIENT.with(|cell| cell.replace(None)); + Some("") + } +); diff --git a/src/jobs.rs b/src/jobs.rs new file mode 100644 index 0000000..bb15509 --- /dev/null +++ b/src/jobs.rs @@ -0,0 +1,64 @@ +//! Job system +use flume::Receiver; +use std::{ + cell::RefCell, + collections::hash_map::{Entry, HashMap}, + thread, +}; + +struct Job { + rx: Receiver, + handle: thread::JoinHandle<()>, +} + +type Output = String; +type JobId = String; + +const NO_RESULTS_YET: &str = "NO RESULTS YET"; +const NO_SUCH_JOB: &str = "NO SUCH JOB"; +const JOB_PANICKED: &str = "JOB PANICKED"; + +#[derive(Default)] +struct Jobs { + map: HashMap, + next_job: usize, +} + +impl Jobs { + fn start Output + Send + 'static>(&mut self, f: F) -> JobId { + let (tx, rx) = flume::unbounded(); + let handle = thread::spawn(move || { + let _ = tx.send(f()); + }); + let id = self.next_job.to_string(); + self.next_job += 1; + self.map.insert(id.clone(), Job { rx, handle }); + id + } + + fn check(&mut self, id: &str) -> Output { + let entry = match self.map.entry(id.to_owned()) { + Entry::Occupied(occupied) => occupied, + Entry::Vacant(_) => return NO_SUCH_JOB.to_owned(), + }; + let result = match entry.get().rx.try_recv() { + Ok(result) => result, + Err(flume::TryRecvError::Disconnected) => JOB_PANICKED.to_owned(), + Err(flume::TryRecvError::Empty) => return NO_RESULTS_YET.to_owned(), + }; + let _ = entry.remove().handle.join(); + result + } +} + +thread_local! { + static JOBS: RefCell = RefCell::default(); +} + +pub fn start Output + Send + 'static>(f: F) -> JobId { + JOBS.with(|jobs| jobs.borrow_mut().start(f)) +} + +pub fn check(id: &str) -> String { + JOBS.with(|jobs| jobs.borrow_mut().check(id)) +} diff --git a/src/lib.rs b/src/lib.rs index 2ab7c23..56a63e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,8 +5,13 @@ mod byond; #[allow(dead_code)] mod error; +#[cfg(feature = "jobs")] +mod jobs; + #[cfg(feature = "file")] pub mod file; +#[cfg(feature = "http")] +pub mod http; #[cfg(feature = "regexp")] pub mod regexp; #[cfg(feature = "transliteration")]