diff --git a/Cargo.lock b/Cargo.lock index f1782bb..67cd17a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.3.0" @@ -108,6 +114,12 @@ version = "0.21.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bit_field" version = "0.10.2" @@ -325,6 +337,15 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3dca9240753cf90908d7e4aac30f630662b02aebaa1b58a3cadabdb23385b58b" +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -392,10 +413,10 @@ dependencies = [ "cookie", "futures-core", "futures-util", - "http", - "hyper", - "hyper-rustls", - "hyper-tls", + "http 0.2.12", + "hyper 0.14.28", + "hyper-rustls 0.23.2", + "hyper-tls 0.5.0", "mime", "serde", "serde_json", @@ -595,6 +616,25 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" +[[package]] +name = "h2" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.4.1" @@ -634,6 +674,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -641,7 +692,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -667,8 +741,8 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -680,19 +754,56 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "h2", + "http 1.1.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + [[package]] name = "hyper-rustls" version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788965e61b367cd03a62950836d5cd41560c3577d90e40e0819373194d1661c" dependencies = [ - "http", - "hyper", + "http 0.2.12", + "hyper 0.14.28", "log", - "rustls", + "rustls 0.20.9", "rustls-native-certs", "tokio", - "tokio-rustls", + "tokio-rustls 0.23.4", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.4.1", + "hyper-util", + "rustls 0.23.11", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.0", + "tower-service", ] [[package]] @@ -702,12 +813,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.28", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.4.1", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.4.1", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + [[package]] name = "idna" version = "0.5.0" @@ -778,6 +925,12 @@ dependencies = [ "syn", ] +[[package]] +name = "ipnet" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + [[package]] name = "itertools" version = "0.12.1" @@ -1167,6 +1320,26 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.14" @@ -1392,6 +1565,49 @@ dependencies = [ "thiserror", ] +[[package]] +name = "reqwest" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.4.1", + "hyper-rustls 0.27.2", + "hyper-tls 0.6.0", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 2.1.2", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "rgb" version = "0.8.37" @@ -1462,6 +1678,19 @@ dependencies = [ "webpki", ] +[[package]] +name = "rustls" +version = "0.23.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4828ea528154ae444e5a642dbb7d5623354030dc9822b83fd9bb79683c7399d0" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + [[package]] name = "rustls-native-certs" version = "0.6.3" @@ -1469,7 +1698,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ "openssl-probe", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "schannel", "security-framework", ] @@ -1483,6 +1712,33 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64 0.22.1", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" + +[[package]] +name = "rustls-webpki" +version = "0.102.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9a6fccd794a42c2c105b513a2f62bc3fd8f3ba57a4593677ceb0bd035164d78" +dependencies = [ + "ring 0.17.8", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustyline" version = "8.2.0" @@ -1535,6 +1791,7 @@ dependencies = [ "fantoccini", "futures", "image", + "reqwest", "scout-lexer", "scout-parser", "serde", @@ -1648,6 +1905,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "signal-hook-registry" version = "1.4.2" @@ -1718,6 +1987,12 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e08d8363704e6c71fc928674353e6b7c23dcea9d82d7012c8faf2a3a025f8d0" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.64" @@ -1729,6 +2004,33 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.2.1", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -1916,11 +2218,35 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" dependencies = [ - "rustls", + "rustls 0.20.9", "tokio", "webpki", ] +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls 0.23.11", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.8.14" @@ -1955,6 +2281,27 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + [[package]] name = "tower-service" version = "0.3.2" @@ -2117,6 +2464,18 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.92" @@ -2165,7 +2524,7 @@ dependencies = [ "base64 0.13.1", "bytes", "cookie", - "http", + "http 0.2.12", "log", "serde", "serde_derive", @@ -2361,6 +2720,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/scout-interpreter/Cargo.toml b/scout-interpreter/Cargo.toml index 3352c16..7e5eb37 100644 --- a/scout-interpreter/Cargo.toml +++ b/scout-interpreter/Cargo.toml @@ -25,6 +25,7 @@ serde_json = "1.0" image = "0.25.1" scout-lexer = { version = "0.5.3", path = "../scout-lexer/" } url = "2.5.2" +reqwest = { version = "0.12", features = ["json"] } [dev-dependencies] test-case = "3.3.1" diff --git a/scout-interpreter/src/builtin.rs b/scout-interpreter/src/builtin.rs index efb0bb5..96f017c 100644 --- a/scout-interpreter/src/builtin.rs +++ b/scout-interpreter/src/builtin.rs @@ -1,13 +1,16 @@ -use std::{collections::HashMap, env, sync::Arc, thread::sleep, time::Duration}; +use std::{collections::HashMap, env, str::FromStr, sync::Arc, thread::sleep, time::Duration}; use fantoccini::{ actions::{InputSource, KeyAction, KeyActions}, - client, cookies::Cookie, elements::Element, key::Key, }; use futures::{future::BoxFuture, lock::Mutex, FutureExt, TryFutureExt}; +use reqwest::{ + header::{HeaderMap, HeaderName, HeaderValue}, + Method, +}; use scout_parser::ast::Identifier; use crate::{object::Object, EvalError, EvalResult, ScrapeResultsPtr}; @@ -41,6 +44,8 @@ pub enum BuiltinKind { Push, Cookies, SetCookies, + ToJson, + HttpRequest, } impl BuiltinKind { @@ -66,6 +71,8 @@ impl BuiltinKind { "push" => Some(Push), "cookies" => Some(Cookies), "setCookies" => Some(SetCookies), + "toJson" => Some(ToJson), + "httpRequest" => Some(HttpRequest), _ => None, } } @@ -78,6 +85,55 @@ impl BuiltinKind { ) -> EvalResult { use BuiltinKind::*; match self { + HttpRequest => { + if args.len() < 4 { + return Err(EvalError::InvalidFnParams); + } + + match (&*args[0], &*args[1]) { + (Object::Str(method), Object::Str(url)) => { + let client = reqwest::Client::new(); + let method = Method::from_str(method) + .map_err(|_| EvalError::InvalidHTTPMethod(method.to_string()))?; + let mut req_builder = client.request(method, url); + + // Check for an optional body + if args[2].is_truthy().await { + let body = args[2].to_string(); + req_builder = req_builder.body(body); + } + + // Check for an optional headers map + if let Object::Map(map) = &*args[3] { + let mut headers = HeaderMap::default(); + let inner = map.lock().await; + for (k, v) in inner.iter() { + headers.insert( + HeaderName::try_from(k.name.clone()).map_err(|_| { + EvalError::InvalidHTTPHeaderKey(k.name.clone()) + })?, + HeaderValue::from_str(&v.to_string()).map_err(|_| { + EvalError::InvalidHTTPHeaderValue(v.to_string()) + })?, + ); + } + req_builder = req_builder.headers(headers); + } + let res = req_builder.send().await?; + let kvs = vec![( + Identifier::new("statusCode".to_string()), + Arc::new(Object::Number(res.status().as_u16() as f64)), + )]; + Ok(Arc::new(Object::Map(Mutex::new(kvs.into_iter().collect())))) + } + _ => Err(EvalError::InvalidFnParams), + } + } + ToJson => { + assert_param_len!(args, 1); + let json = args[0].to_json().await; + Ok(Arc::new(Object::Str(json.to_string()))) + } Cookies => { let cookies = crawler .get_all_cookies() @@ -283,6 +339,16 @@ impl BuiltinKind { } Ok(Arc::new(Object::Boolean(contains))) } + Object::Map(m) => { + if let Object::Str(id) = &*args[1] { + let ident = Identifier::new(id.clone()); + let inner = m.lock().await; + let contains = inner.contains_key(&ident); + Ok(Arc::new(Object::Boolean(contains))) + } else { + Err(EvalError::InvalidFnParams) + } + } _ => Err(EvalError::InvalidFnParams), } } @@ -313,3 +379,9 @@ async fn apply_elem_fn( _ => Err(EvalError::InvalidFnParams), } } + +impl From for EvalError { + fn from(value: reqwest::Error) -> Self { + EvalError::HTTPError(value) + } +} diff --git a/scout-interpreter/src/lib.rs b/scout-interpreter/src/lib.rs index 750d117..699dbec 100644 --- a/scout-interpreter/src/lib.rs +++ b/scout-interpreter/src/lib.rs @@ -86,6 +86,10 @@ pub enum EvalError { InvalidImport(ImportError), InvalidIndex, InvalidAssign, + InvalidHTTPMethod(String), + InvalidHTTPHeaderKey(String), + InvalidHTTPHeaderValue(String), + HTTPError(reqwest::Error), IndexOutOfBounds, NonFunction, UnknownIdent(Identifier), diff --git a/scout-interpreter/src/object.rs b/scout-interpreter/src/object.rs index fa1f0a7..6937c70 100644 --- a/scout-interpreter/src/object.rs +++ b/scout-interpreter/src/object.rs @@ -143,35 +143,6 @@ impl Object { } .boxed() } -} - -impl Display for Object { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use Object::*; - match self { - Null => write!(f, "Null"), - Str(s) => write!(f, "\"{}\"", s), - Node(_) => write!(f, "Node"), - List(_objs) => write!(f, "list"), - Boolean(b) => write!(f, "{}", b), - Number(n) => write!(f, "{}", n), - _ => write!(f, "object"), - } - } -} - -impl Object { - fn vec_to_json<'a>(&'a self, v: &'a Mutex>>) -> BoxFuture<'a, Value> { - async move { - let mut out = Vec::new(); - let inner = v.lock().await; - for obj in &*inner { - out.push(obj.clone().to_json().await); - } - Value::Array(out) - } - .boxed() - } pub async fn to_json(&self) -> Value { use Object::*; @@ -180,7 +151,7 @@ impl Object { Str(s) => Value::String(s.to_owned()), // @TODO handle this better Node(_) => Value::String("Node".to_owned()), - List(list) => self.vec_to_json(list).await, + List(list) => vec_to_json(list).await, Map(map) => { let inner = map.lock().await; Value::Object(obj_map_to_json(&*inner).await) @@ -207,6 +178,33 @@ impl Object { } } +impl Display for Object { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + use Object::*; + match self { + Null => write!(f, "Null"), + Str(s) => write!(f, "\"{}\"", s), + Node(_) => write!(f, "Node"), + List(_objs) => write!(f, "list"), + Boolean(b) => write!(f, "{}", b), + Number(n) => write!(f, "{}", n), + _ => write!(f, "object"), + } + } +} + +fn vec_to_json<'a>(v: &'a Mutex>>) -> BoxFuture<'a, Value> { + async move { + let mut out = Vec::new(); + let inner = v.lock().await; + for obj in &*inner { + out.push(obj.clone().to_json().await); + } + Value::Array(out) + } + .boxed() +} + pub fn obj_map_to_json( map: &HashMap>, ) -> BoxFuture> { diff --git a/scout-lib/http.sct b/scout-lib/http.sct new file mode 100644 index 0000000..6cab232 --- /dev/null +++ b/scout-lib/http.sct @@ -0,0 +1,11 @@ +def get(url, headers = null) do + httpRequest("GET", url, null, headers) +end + +def post(url, body = null, headers = null) do + httpRequest("POST", url, body, headers) +end + +def put(url, body = null, headers = null) do + httpRequest("PUT", url, body, headers) +end \ No newline at end of file