Skip to content

Commit

Permalink
add http client (#1)
Browse files Browse the repository at this point in the history
* add http client

* format http.rs properly

* update lib macro on byond side

* fix tests

* update byond macros and procs name
  • Loading branch information
Gaxeer authored Jan 27, 2025
1 parent d8f5562 commit a0a4025
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 15 deletions.
29 changes: 17 additions & 12 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rust-utils"
version = "0.1.0"
version = "0.2.0"
edition = "2021"

[lib]
Expand All @@ -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"]
13 changes: 13 additions & 0 deletions dmsrc/http.dm
Original file line number Diff line number Diff line change
@@ -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")()
8 changes: 7 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::{io, result, str::Utf8Error};
use thiserror::Error;

pub type BResult<T> = result::Result<T, Error>;
pub type Result<T> = result::Result<T, Error>;

#[derive(Error, Debug)]
pub enum Error {
Expand All @@ -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<ureq::Error>),
}

impl From<Utf8Error> for Error {
Expand Down
4 changes: 2 additions & 2 deletions src/file.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::error::BResult;
use crate::error::Result;
use base64::Engine;
use std::{
fs::File,
Expand All @@ -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<usize> {
fn write(data: &str, path: &str, base64decode: bool) -> Result<usize> {
let path: &std::path::Path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
Expand Down
182 changes: 182 additions & 0 deletions src/http.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
#[serde(default)]
body_filename: Option<String>,
}

#[derive(Serialize)]
struct Response<'a> {
status_code: u16,
headers: HashMap<String, String>,
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<Option<ureq::Agent>> = RefCell::new(Some(ureq::agent()));
}

// ----------------------------------------------------------------------------
// Request construction and execution

struct RequestPrep {
req: ureq::Request,
output_filename: Option<String>,
body: Vec<u8>,
}

fn construct_request(
method: &str,
url: &str,
body: &str,
headers: &str,
options: &str,
) -> Result<RequestPrep> {
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<String> {
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("")
}
);
64 changes: 64 additions & 0 deletions src/jobs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! Job system
use flume::Receiver;
use std::{
cell::RefCell,
collections::hash_map::{Entry, HashMap},
thread,
};

struct Job {
rx: Receiver<Output>,
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<JobId, Job>,
next_job: usize,
}

impl Jobs {
fn start<F: FnOnce() -> 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<Jobs> = RefCell::default();
}

pub fn start<F: FnOnce() -> 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))
}
5 changes: 5 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down

0 comments on commit a0a4025

Please sign in to comment.