diff --git a/.circleci/config.yml b/.circleci/config.yml index 3b7fe70d49..d6db453f8c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -107,6 +107,7 @@ commands: - run: sudo apt-get install python tcl - run: sudo apt-get install python3-venv - run: sudo apt-get install libclang-dev + - run: sudo apt-get install libssl-dev - run: name: Install NSS build system dependencies command: sudo apt-get install ninja-build gyp zlib1g-dev pip diff --git a/Cargo.lock b/Cargo.lock index a7353e999e..7cbd4550ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1439,6 +1439,33 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fairy-bridge" +version = "0.1.0" +dependencies = [ + "async-trait", + "oneshot", + "pollster", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "uniffi", + "url", +] + +[[package]] +name = "fairy-bridge-demo" +version = "0.1.0" +dependencies = [ + "fairy-bridge", + "serde", + "serde_json", + "uniffi", + "url", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1679,6 +1706,19 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "generator" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" +dependencies = [ + "cc", + "libc", + "log", + "rustversion", + "windows", +] + [[package]] name = "generic-array" version = "0.14.5" @@ -2316,6 +2356,20 @@ dependencies = [ "url", ] +[[package]] +name = "loom" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" +dependencies = [ + "cfg-if 1.0.0", + "generator", + "pin-utils", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2325,6 +2379,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matchit" version = "0.7.0" @@ -2728,6 +2791,16 @@ dependencies = [ "nss_build_common", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num" version = "0.4.1" @@ -2901,6 +2974,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +[[package]] +name = "oneshot" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6640c6bda7731b1fdbab747981a0f896dd1fedaf9f4a53fa237a04a84431f4" +dependencies = [ + "loom", +] + [[package]] name = "oneshot-uniffi" version = "0.1.6" @@ -2991,6 +3073,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.12.1" @@ -3178,6 +3266,12 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + [[package]] name = "powerfmt" version = "0.2.0" @@ -3523,7 +3617,7 @@ dependencies = [ "aho-corasick", "memchr", "regex-automata 0.3.7", - "regex-syntax", + "regex-syntax 0.7.5", ] [[package]] @@ -3531,6 +3625,9 @@ name = "regex-automata" version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", +] [[package]] name = "regex-automata" @@ -3540,9 +3637,15 @@ checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.7.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.7.5" @@ -3589,9 +3692,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.11.18" +version = "0.11.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" dependencies = [ "async-compression", "base64", @@ -3612,9 +3715,12 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", + "sync_wrapper", + "system-configuration", "tokio", "tokio-native-tls", "tokio-util", @@ -3732,6 +3838,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + [[package]] name = "rustversion" version = "1.0.12" @@ -3931,6 +4046,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4198,6 +4322,27 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "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 = "systest" version = "0.1.0" @@ -4343,6 +4488,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "thread_local" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" +dependencies = [ + "cfg-if 1.0.0", + "once_cell", +] + [[package]] name = "time" version = "0.3.34" @@ -4539,9 +4694,21 @@ dependencies = [ "cfg-if 1.0.0", "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.32", +] + [[package]] name = "tracing-core" version = "0.1.31" @@ -4549,6 +4716,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -4822,6 +5019,12 @@ dependencies = [ "serde", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "vcpkg" version = "0.2.15" @@ -5174,6 +5377,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.0", +] + [[package]] name = "windows-sys" version = "0.36.1" @@ -5399,11 +5611,12 @@ checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" [[package]] name = "winreg" -version = "0.10.1" +version = "0.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ - "winapi", + "cfg-if 1.0.0", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 99bcc26b73..24dffe8ccb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "components/as-ohttp-client", "components/autofill", "components/crashtest", + "components/fairy-bridge", "components/fxa-client", "components/logins", "components/nimbus", diff --git a/components/fairy-bridge/Cargo.toml b/components/fairy-bridge/Cargo.toml new file mode 100644 index 0000000000..871c2424e0 --- /dev/null +++ b/components/fairy-bridge/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "fairy-bridge" +version = "0.1.0" +edition = "2021" + +[features] +default = [] +backend-reqwest = ["dep:reqwest"] +backend-c = ["dep:oneshot"] + +[dependencies] +async-trait = "0.1" +oneshot = { version = "0.1", optional = true } +pollster = "0.3.0" +serde = "1" +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["rt-multi-thread"] } +uniffi = { workspace = true } +url = "2.2" +reqwest = { version = "0.11.23", optional = true } + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } diff --git a/components/fairy-bridge/README.md b/components/fairy-bridge/README.md new file mode 100644 index 0000000000..6fea6349c8 --- /dev/null +++ b/components/fairy-bridge/README.md @@ -0,0 +1,55 @@ +# Fairy Bridge + +Fairy Bridge is an HTTP request bridge library that allows requests to be made using various +backends, including: + + - The builtin reqwest backend + - Custom Rust backends + - Custom backends written in the foreign language + +The plan for this is: + - iOS will use the reqwest backend + - Android will use a custom backend in Kotlin using fetch + (https://github.com/mozilla-mobile/firefox-android/tree/35ce01367157440f9e9daa4ed48a8022af80c8f2/android-components/components/concept/fetch) + - Desktop will use a custom backend in Rust that hooks into necko + +## Sync / Async + +The backends are implemented using async code, but there's also the option to block on a request. +This means `fairy-bridge` can be used in both sync and async contexts. + +## Cookies / State + +Cookies and state are outside the scope of this library. Any such functionality is the responsibility of the consumer. + +## Name + +`fairy-bridge` is named after the Fairy Bridge (Xian Ren Qiao) -- the largest known natural bridge in the world, located in northwestern Guangxi Province, China. + +![Picture of the Fairy Bridge](http://www.naturalarches.org/big9_files/FairyBridge1680.jpg) + +# Backends + +## Reqwest + +- Handle requests using the Rust [reqwest library](https://docs.rs/reqwest/latest/reqwest/). +- This backend creates a tokio thread to execute the requests. +- Call `fairy_bridge::init_backend_reqwest` to select this backend. + +## Foreign code + +- The foreign code can implement a backend themselves by implementing the `fairy_bridge::Backend` trait. +- Pass an instance of the object that implements the trait to `fairy_bridge::init_backend` to select this backend. + +## C / C++ code + +- A backend can also be implemented in C / C++ code +- Include the `c-backend-include/fairy_bridge.h` file. +- Implement the `fairy_bridge_backend_c_send_request` function. +- Call `fairy_bridge::init_backend_c` to select this backend (from the bindings language, not C). +- See `examples/fairy-bridge-demo` for a code example. + +## (Coming soon) Necko backend + +- The geckoview `libxul` library comes with a Necko-based c backend. +- Link to `libxul` and call `fairy_bridge::init_backend_c` to select this backend. diff --git a/components/fairy-bridge/c-backend-include/fairy_bridge.h b/components/fairy-bridge/c-backend-include/fairy_bridge.h new file mode 100644 index 0000000000..952b9dcb9e --- /dev/null +++ b/components/fairy-bridge/c-backend-include/fairy_bridge.h @@ -0,0 +1,100 @@ +#include + +namespace fairy_bridge { + +struct BackendSettings { + uint32_t timeout; + uint32_t connect_timeout; + uint32_t redirect_limit; +}; + + +enum class Method { + Get, + Head, + Post, + Put, + Delete, + Connect, + Options, + Trace, + Patch, +}; + +struct Header { + const char* key; + const char* value; +}; + +struct Request { + Method method; + const char* url; + Header* headers; + size_t header_count; + const char* body; + size_t body_length; +}; + +/** + * Opaque HTTP result type + */ +struct Result; + +/** + * Calls used to build up a Result. + * + * Strings are passed as (const char*, size_t), pairs since this is often easier for backends to work with. + */ +extern "C" { + void fairy_bridge_result_set_url(Result* result, const char* url, size_t length); + void fairy_bridge_result_set_status_code(Result* result, uint16_t code); + void fairy_bridge_result_add_header(Result* result, const char* key, size_t key_length, const char* value, size_t value_length); + void fairy_bridge_result_extend_body(Result* result, const char* data, size_t length); +} + +/** + * Complete a result + * + * Call this after the result has been successfully built using the previous methods. This + * consumes the result pointer and it should not be used again by the backend. + */ +extern "C" { + void fairy_bridge_result_complete(Result* result); +} + +/** + * Complete a result with an error + * + * This causes an error to be returned for the result. Any previous builder calls will be + * ignored. This consumes the result pointer and it should not be used again by the backend. + */ +extern "C" { + void fairy_bridge_result_complete_error(Result* result, const char* message, size_t length); +} + +} // namespace fairy_bridge + +/** + * Backend API + * + * This must be implemented by the backend code. + */ +extern "C" { + /** + * Initialize the backend. This is called once at startup. + */ + void fairy_bridge_backend_c_init(fairy_bridge::BackendSettings settings); + + /** + * Perform a rquest + * + * The backend should schedule the request to be performed in a separate thread. + * + * The result is initially empty. It should be built up and completed by the + * `fairy_bridge_result_*` functions. + * + * `request` and `result` are valid until `fairy_bridge_result_complete` or + * `fairy_bridge_result_complete_error` is called. After that they should not be used. + */ + void fairy_bridge_backend_c_send_request(fairy_bridge::Request* request, fairy_bridge::Result* result); +} diff --git a/components/fairy-bridge/src/backend.rs b/components/fairy-bridge/src/backend.rs new file mode 100644 index 0000000000..cac6677e99 --- /dev/null +++ b/components/fairy-bridge/src/backend.rs @@ -0,0 +1,38 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::{FairyBridgeError, Request, Response}; +use std::sync::Arc; + +/// Settings for a backend instance +/// +/// Backend constructions should input this in order to configure themselves +/// +/// Repr(C) so we can pass it to the C backend +#[derive(Debug, uniffi::Record)] +#[repr(C)] +pub struct BackendSettings { + // Connection timeout in ms (0 indicates no timeout). + #[uniffi(default = 0)] + pub connect_timeout: u32, + // Timeout for the entire request in ms (0 indicates no timeout). + #[uniffi(default = 0)] + pub timeout: u32, + // Maximum amount of redirects to follow (0 means redirects are not allowed) + #[uniffi(default = 10)] + pub redirect_limit: u32, +} + +#[uniffi::export(with_foreign)] +#[async_trait::async_trait] +pub trait Backend: Send + Sync { + async fn send_request(self: Arc, request: Request) -> Result; +} + +#[uniffi::export] +pub fn init_backend(backend: Arc) -> Result<(), FairyBridgeError> { + crate::REGISTERED_BACKEND + .set(backend) + .map_err(|_| FairyBridgeError::BackendAlreadyInitialized) +} diff --git a/components/fairy-bridge/src/backends/c.rs b/components/fairy-bridge/src/backends/c.rs new file mode 100644 index 0000000000..695e57d9ec --- /dev/null +++ b/components/fairy-bridge/src/backends/c.rs @@ -0,0 +1,237 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::{ptr, slice, sync::Arc}; + +use crate::{ + init_backend, Backend, BackendSettings, FairyBridgeError, Method, Request, Response, Result, +}; + +const NULL: char = '\0'; + +/// Request for the backend +#[repr(C)] +pub struct FfiRequest { + method: Method, + url: *mut u8, + headers: *mut FfiHeader, + header_count: usize, + body: *mut u8, +} + +#[repr(C)] +pub struct FfiHeader { + key: *mut u8, + value: *mut u8, +} + +/// Result from the backend +/// +/// This is built-up piece by piece using the extern "C" API. +pub struct FfiResult { + // oneshot sender that the Rust code is awaiting. If `Ok(())` is sent, then the Rust code + // should return the response. If an error is sent, then that should be returned instead. + sender: Option>>, + response: Response, + // Owned values stored in the [FfiRequest]. These are copied from the request. By storing + // them in the result, we ensure they stay alive while the C code may access them. + pub url: String, + pub headers: Vec<(String, String)>, + pub body: Option>, +} + +// Function that the C / C++ library exports for us +extern "C" { + fn fairy_bridge_backend_c_init(settings: BackendSettings); + + // (Rust flags this as an "improper C type", but the C code only uses it as an opaque pointer). + #[allow(improper_ctypes)] + fn fairy_bridge_backend_c_send_request(request: &mut FfiRequest, result: &mut FfiResult); +} + +// Functions that we provide to the C / C++ library + +/// Set the URL for a result +/// +/// # Safety +/// +/// - `result` must be valid. +/// - `url` and `length` must refer to a valid UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn fairy_bridge_result_set_url( + result: &mut FfiResult, + url: *mut u8, + length: usize, +) { + // Safety: this is safe as long as the backend passes us valid data + result.response.url = + unsafe { String::from_utf8_unchecked(slice::from_raw_parts_mut(url, length).to_vec()) }; +} + +/// Set the status code for a result +/// +/// # Safety +/// +/// `result` must be valid. +#[no_mangle] +pub unsafe extern "C" fn fairy_bridge_result_set_status_code(result: &mut FfiResult, code: u16) { + result.response.status = code; +} + +/// Set a header for a result +/// +/// # Safety +/// +/// - `result` must be valid. +/// - `key` and `key_length` must refer to a valid UTF-8 string. +/// - `value` and `value_length` must refer to a valid UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn fairy_bridge_result_add_header( + result: &mut FfiResult, + key: *mut u8, + key_length: usize, + value: *mut u8, + value_length: usize, +) { + // Safety: this is safe as long as the backend passes us valid data + let (key, value) = unsafe { + ( + String::from_utf8_unchecked(slice::from_raw_parts_mut(key, key_length).to_vec()), + String::from_utf8_unchecked(slice::from_raw_parts_mut(value, value_length).to_vec()), + ) + }; + result.response.headers.insert(key, value); +} + +/// Append data to a result body +/// +/// This method can be called multiple times to build up the body in chunks. +/// +/// # Safety +/// +/// - `result` must be valid. +/// - `data` and `length` must refer to a binary string. +#[no_mangle] +pub unsafe extern "C" fn fairy_bridge_result_extend_body( + result: &mut FfiResult, + data: *mut u8, + length: usize, +) { + // Safety: this is safe as long as the backend passes us valid data + result + .response + .body + .extend_from_slice(unsafe { slice::from_raw_parts_mut(data, length) }); +} + +/// Complete a result +/// +/// # Safety +/// +/// `result` must be valid. After calling this function it must not be used again. +#[no_mangle] +pub unsafe extern "C" fn fairy_bridge_result_complete(result: &mut FfiResult) { + match result.sender.take() { + Some(sender) => { + // Ignore any errors when sending the result. This happens when the receiver is + // closed, which happens when a future is cancelled. + let _ = sender.send(Ok(())); + } + None => println!("fairy_bridge: result completed twice"), + } +} + +/// Complete a result with an error message +/// +/// # Safety +/// +/// - `result` must be valid. After calling this function it must not be used again. +/// - `message` and `length` must refer to a valid UTF-8 string. +#[no_mangle] +pub unsafe extern "C" fn fairy_bridge_result_complete_error( + result: &mut FfiResult, + message: *mut u8, + length: usize, +) { + // Safety: this is safe as long as the backend passes us valid data + let msg = + unsafe { String::from_utf8_unchecked(slice::from_raw_parts_mut(message, length).to_vec()) }; + match result.sender.take() { + Some(sender) => { + // Ignore any errors when sending the result. This happens when the receiver is + // closed, which happens when a future is cancelled. + let _ = sender.send(Err(FairyBridgeError::BackendError { msg })); + } + None => println!("fairy_bridge: result completed twice"), + } +} + +// The C-backend is a zero-sized type, since all the backend functionality is statically linked +struct CBackend; + +#[uniffi::export] +fn init_backend_c(settings: BackendSettings) { + // Safety: this is safe as long as the C code is correct. + unsafe { fairy_bridge_backend_c_init(settings) }; + init_backend(Arc::new(CBackend)).expect("Error initializing C Backend"); +} + +#[async_trait::async_trait] +impl Backend for CBackend { + async fn send_request(self: Arc, mut request: Request) -> Result { + // Convert the request for the backend + request.url.push(NULL); + let mut header_list: Vec<_> = request.headers.into_iter().collect(); + for (key, value) in header_list.iter_mut() { + key.push(NULL); + value.push(NULL); + } + let mut ffi_headers: Vec<_> = header_list + .iter_mut() + .map(|(key, value)| FfiHeader { + key: key.as_mut_ptr(), + value: value.as_mut_ptr(), + }) + .collect(); + let mut ffi_request = FfiRequest { + method: request.method, + url: request.url.as_mut_ptr(), + headers: ffi_headers.as_mut_ptr(), + header_count: ffi_headers.len(), + body: match &mut request.body { + Some(body) => body.as_mut_ptr(), + None => ptr::null_mut(), + }, + }; + + // Prepare an FfiResult with an empty response + let (sender, receiver) = oneshot::channel(); + let mut result = FfiResult { + sender: Some(sender), + response: Response::default(), + url: request.url, + headers: header_list, + body: request.body, + }; + + // Safety: this is safe if the C backend implements the API correctly. + unsafe { + fairy_bridge_backend_c_send_request(&mut ffi_request, &mut result); + }; + receiver + .await + .unwrap_or_else(|e| { + Err(FairyBridgeError::BackendError { + msg: format!("Error receiving result: {e}"), + }) + }) + .map(|_| result.response) + } +} + +// Mark FFI types as Send to allow them to be used across an await point. This is safe as long as +// the backend code uses them correctly. +unsafe impl Send for FfiRequest {} +unsafe impl Send for FfiResult {} +unsafe impl Send for FfiHeader {} diff --git a/components/fairy-bridge/src/backends/mod.rs b/components/fairy-bridge/src/backends/mod.rs new file mode 100644 index 0000000000..29fc829da9 --- /dev/null +++ b/components/fairy-bridge/src/backends/mod.rs @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#[cfg(feature = "backend-c")] +mod c; +#[cfg(feature = "backend-reqwest")] +mod reqwest; + +#[cfg(feature = "backend-c")] +pub use c::*; +#[cfg(feature = "backend-reqwest")] +pub use reqwest::*; diff --git a/components/fairy-bridge/src/backends/reqwest.rs b/components/fairy-bridge/src/backends/reqwest.rs new file mode 100644 index 0000000000..af55b64097 --- /dev/null +++ b/components/fairy-bridge/src/backends/reqwest.rs @@ -0,0 +1,123 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::{init_backend, Backend, BackendSettings, FairyBridgeError, Method, Request, Response}; +use std::{sync::Arc, time::Duration}; + +struct ReqwestBackend { + runtime: tokio::runtime::Runtime, + client: reqwest::Client, +} + +#[uniffi::export] +pub fn init_backend_reqwest(settings: BackendSettings) -> Result<(), FairyBridgeError> { + // Create a multi-threaded runtime, with 1 worker thread. + // + // This creates and manages a single worker thread. + // + // Tokio also provides the current thread runtime, which is a "single-threaded future executor". + // However that means it needs to block a thread to run tasks. + // I.e. `send_request` would block to while executing the request. + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(1) + .enable_all() + .build() + .unwrap(); + let mut client_builder = reqwest::Client::builder(); + if settings.connect_timeout > 0 { + client_builder = + client_builder.connect_timeout(Duration::from_millis(settings.connect_timeout as u64)) + } + if settings.timeout > 0 { + client_builder = client_builder.timeout(Duration::from_millis(settings.timeout as u64)) + } + client_builder = client_builder.redirect(reqwest::redirect::Policy::limited( + settings.redirect_limit as usize, + )); + let client = client_builder.build()?; + let backend = Arc::new(ReqwestBackend { runtime, client }); + init_backend(backend) +} + +#[async_trait::async_trait] +impl Backend for ReqwestBackend { + async fn send_request(self: Arc, request: Request) -> Result { + let handle = self.runtime.handle().clone(); + match handle + .spawn(async move { + self.convert_response(self.make_request(request).await?) + .await + }) + .await + { + Ok(result) => result, + Err(e) => Err(FairyBridgeError::BackendError { + msg: format!("tokio error: {e}"), + }), + } + } +} + +impl ReqwestBackend { + async fn make_request(&self, request: Request) -> Result { + let method = match request.method { + Method::Get => reqwest::Method::GET, + Method::Head => reqwest::Method::HEAD, + Method::Post => reqwest::Method::POST, + Method::Put => reqwest::Method::PUT, + Method::Delete => reqwest::Method::DELETE, + Method::Connect => reqwest::Method::CONNECT, + Method::Options => reqwest::Method::OPTIONS, + Method::Trace => reqwest::Method::TRACE, + Method::Patch => reqwest::Method::PATCH, + }; + let mut builder = self.client.request(method, request.url); + for (key, value) in request.headers { + builder = builder.header(key, value); + } + if let Some(body) = request.body { + builder = builder.body(body) + } + Ok(builder.send().await?) + } + + async fn convert_response( + &self, + response: reqwest::Response, + ) -> Result { + let url = response.url().to_string(); + let status = response.status().as_u16(); + let headers = response + .headers() + .iter() + .map(|(k, v)| { + ( + k.as_str().to_owned(), + String::from_utf8_lossy(v.as_bytes()).to_string(), + ) + }) + .collect(); + let body = response.bytes().await?.into(); + + Ok(Response { + url, + status, + headers, + body, + }) + } +} + +impl From for FairyBridgeError { + fn from(error: reqwest::Error) -> Self { + match error.status() { + Some(status) => FairyBridgeError::HttpError { + code: status.as_u16(), + }, + None => FairyBridgeError::BackendError { + msg: format!("reqwest error: {error}"), + }, + } + } +} diff --git a/components/fairy-bridge/src/error.rs b/components/fairy-bridge/src/error.rs new file mode 100644 index 0000000000..9c13cb1a3b --- /dev/null +++ b/components/fairy-bridge/src/error.rs @@ -0,0 +1,29 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum FairyBridgeError { + #[error("BackendAlreadyInitialized")] + BackendAlreadyInitialized, + #[error("NoBackendInitialized")] + NoBackendInitialized, + #[error("BackendError({msg})")] + BackendError { msg: String }, + #[error("HttpError({code})")] + HttpError { code: u16 }, + #[error("InvalidRequestHeader({name})")] + InvalidRequestHeader { name: String }, + #[error("InvalidResponseHeader({name})")] + InvalidResponseHeader { name: String }, + #[error("SerializationError({msg})")] + SerializationError { msg: String }, +} + +impl From for FairyBridgeError { + fn from(e: serde_json::Error) -> Self { + Self::SerializationError { msg: e.to_string() } + } +} diff --git a/components/fairy-bridge/src/headers.rs b/components/fairy-bridge/src/headers.rs new file mode 100644 index 0000000000..3c7de7824f --- /dev/null +++ b/components/fairy-bridge/src/headers.rs @@ -0,0 +1,81 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::FairyBridgeError; +use std::borrow::Cow; + +/// Normalize / validate a request header +/// +/// This accepts both &str and String. It either returns the lowercase version or +/// `FairyBridgeError::InvalidRequestHeader` +pub fn normalize_request_header<'a>(name: impl Into>) -> crate::Result { + do_normalize_header(name).map_err(|name| FairyBridgeError::InvalidRequestHeader { name }) +} + +/// Normalize / validate a response header +/// +/// This accepts both &str and String. It either returns the lowercase version or +/// `FairyBridgeError::InvalidRequestHeader` +pub fn normalize_response_header<'a>(name: impl Into>) -> crate::Result { + do_normalize_header(name).map_err(|name| FairyBridgeError::InvalidResponseHeader { name }) +} + +fn do_normalize_header<'a>(name: impl Into>) -> Result { + // Note: 0 = invalid, 1 = valid, 2 = valid but needs lowercasing. I'd use an + // enum for this, but it would make this LUT *way* harder to look at. This + // includes 0-9, a-z, A-Z (as 2), and ('!' | '#' | '$' | '%' | '&' | '\'' | '*' + // | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~'), matching the field-name + // token production defined at https://tools.ietf.org/html/rfc7230#section-3.2. + static VALID_HEADER_LUT: [u8; 256] = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, + 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, + 2, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + ]; + + let mut name = name.into(); + + if name.len() == 0 { + return Err(name.to_string()); + } + let mut need_lower_case = false; + for b in name.bytes() { + let validity = VALID_HEADER_LUT[b as usize]; + if validity == 0 { + return Err(name.to_string()); + } + if validity == 2 { + need_lower_case = true; + } + } + if need_lower_case { + // Only do this if needed, since it causes us to own the header. + name.to_mut().make_ascii_lowercase(); + } + Ok(name.to_string()) +} + +// Default headers for easy usage +pub const ACCEPT_ENCODING: &str = "accept-encoding"; +pub const ACCEPT: &str = "accept"; +pub const AUTHORIZATION: &str = "authorization"; +pub const CONTENT_TYPE: &str = "content-type"; +pub const ETAG: &str = "etag"; +pub const IF_NONE_MATCH: &str = "if-none-match"; +pub const USER_AGENT: &str = "user-agent"; +// non-standard, but it's convenient to have these. +pub const RETRY_AFTER: &str = "retry-after"; +pub const X_IF_UNMODIFIED_SINCE: &str = "x-if-unmodified-since"; +pub const X_KEYID: &str = "x-keyid"; +pub const X_LAST_MODIFIED: &str = "x-last-modified"; +pub const X_TIMESTAMP: &str = "x-timestamp"; +pub const X_WEAVE_NEXT_OFFSET: &str = "x-weave-next-offset"; +pub const X_WEAVE_RECORDS: &str = "x-weave-records"; +pub const X_WEAVE_TIMESTAMP: &str = "x-weave-timestamp"; +pub const X_WEAVE_BACKOFF: &str = "x-weave-backoff"; diff --git a/components/fairy-bridge/src/lib.rs b/components/fairy-bridge/src/lib.rs new file mode 100644 index 0000000000..4033c9eec1 --- /dev/null +++ b/components/fairy-bridge/src/lib.rs @@ -0,0 +1,34 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use std::{ + collections::HashMap, + sync::{Arc, OnceLock}, +}; + +mod backend; +mod backends; +mod error; +pub mod headers; +mod request; + +pub use backend::*; +// allow(unused) because there will be nothing to export if the `backend-reqwest` feature is +// disabled. +#[allow(unused)] +pub use backends::*; +pub use error::*; +pub use request::*; + +static REGISTERED_BACKEND: OnceLock> = OnceLock::new(); + +#[derive(Default, uniffi::Record)] +pub struct Response { + pub url: String, + pub status: u16, + pub headers: HashMap, + pub body: Vec, +} + +uniffi::setup_scaffolding!(); diff --git a/components/fairy-bridge/src/request.rs b/components/fairy-bridge/src/request.rs new file mode 100644 index 0000000000..d7cceb210f --- /dev/null +++ b/components/fairy-bridge/src/request.rs @@ -0,0 +1,149 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +use crate::{headers, FairyBridgeError, Response, Result}; +use pollster::FutureExt; +use std::borrow::Cow; +use std::collections::HashMap; +use url::Url; + +// repr(C) so that it can be easily used with the C backend. +#[derive(uniffi::Enum)] +#[repr(C)] +pub enum Method { + Get, + Head, + Post, + Put, + Delete, + Connect, + Options, + Trace, + Patch, +} + +#[derive(uniffi::Record)] +pub struct Request { + pub method: Method, + pub url: String, + pub headers: HashMap, + pub body: Option>, +} + +/// Http request +/// +/// These are created using the builder pattern, then sent over the network using the `send()` +/// method. +impl Request { + pub fn new(method: Method, url: Url) -> Self { + Self { + method, + url: url.to_string(), + headers: HashMap::new(), + body: None, + } + } + + pub async fn send(self) -> crate::Result { + let mut response = match crate::REGISTERED_BACKEND.get() { + Some(backend) => backend.clone().send_request(self).await, + None => Err(FairyBridgeError::NoBackendInitialized), + }?; + response.headers = response + .headers + .into_iter() + .map(|(name, value)| Ok((headers::normalize_request_header(name)?, value))) + .collect::>>()?; + Ok(response) + } + + pub fn send_sync(self) -> crate::Result { + self.send().block_on() + } + + /// Alias for `Request::new(Method::Get, url)`, for convenience. + pub fn get(url: Url) -> Self { + Self::new(Method::Get, url) + } + + /// Alias for `Request::new(Method::Patch, url)`, for convenience. + pub fn patch(url: Url) -> Self { + Self::new(Method::Patch, url) + } + + /// Alias for `Request::new(Method::Post, url)`, for convenience. + pub fn post(url: Url) -> Self { + Self::new(Method::Post, url) + } + + /// Alias for `Request::new(Method::Put, url)`, for convenience. + pub fn put(url: Url) -> Self { + Self::new(Method::Put, url) + } + + /// Alias for `Request::new(Method::Delete, url)`, for convenience. + pub fn delete(url: Url) -> Self { + Self::new(Method::Delete, url) + } + + /// Add all the provided headers to the list of headers to send with this + /// request. + pub fn headers<'a, I, K, V>(mut self, to_add: I) -> crate::Result + where + I: IntoIterator, + K: Into>, + V: Into, + { + for (name, value) in to_add { + self = self.header(name, value)? + } + Ok(self) + } + + /// Add the provided header to the list of headers to send with this request. + /// + /// This returns `Err` if `val` contains characters that may not appear in + /// the body of a header. + /// + /// ## Example + /// ``` + /// # use fairy_bridge::{Request, headers}; + /// # use url::Url; + /// # fn main() -> fairy_bridge::Result<()> { + /// # let some_url = url::Url::parse("https://www.example.com").unwrap(); + /// Request::post(some_url) + /// .header(headers::CONTENT_TYPE, "application/json")? + /// .header("My-Header", "Some special value")?; + /// // ... + /// # Ok(()) + /// # } + /// ``` + pub fn header<'a>( + mut self, + name: impl Into>, + val: impl Into, + ) -> crate::Result { + self.headers + .insert(headers::normalize_request_header(name)?, val.into()); + Ok(self) + } + + /// Set this request's body. + pub fn body(mut self, body: impl Into>) -> Self { + self.body = Some(body.into()); + self + } + + /// Set body to a json-serialized value and the the Content-Type header to "application/json". + /// + /// Returns an [crate::Error::SerializationError] if there was there was an error serializing the data. + pub fn json(mut self, val: &(impl serde::Serialize + ?Sized)) -> Result { + self.body = Some(serde_json::to_vec(val)?); + self.headers.insert( + headers::CONTENT_TYPE.to_owned(), + "application/json".to_owned(), + ); + Ok(self) + } +} diff --git a/examples/fairy-bridge-demo/Cargo.toml b/examples/fairy-bridge-demo/Cargo.toml new file mode 100644 index 0000000000..56469ab442 --- /dev/null +++ b/examples/fairy-bridge-demo/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "fairy-bridge-demo" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +crate-type = ["cdylib"] + +[dependencies] +fairy-bridge = { path = "../../components/fairy-bridge", features = ["backend-reqwest", "backend-c"] } +serde = {version = "1", features=["derive"] } +serde_json = "1" +uniffi = { workspace = true } +url = "2.2" + +[build-dependencies] +uniffi = { workspace = true, features = ["build"] } diff --git a/examples/fairy-bridge-demo/README.md b/examples/fairy-bridge-demo/README.md new file mode 100644 index 0000000000..7fca61b143 --- /dev/null +++ b/examples/fairy-bridge-demo/README.md @@ -0,0 +1,93 @@ +# Fairy bridge demo + +This example is meant to demonstrate the fairy-bridge library. + +## Usage + +`./run-demo.py` + +This will perform a request against the `https://httpbin.org/anything` endpoint and printout the +response. + +Arguments: + + * `--python`: Use the Python implemented backend (default is the reqwest backend) + * `--sync`: Run the request in sync mode (default is async mode) + * `--post`: Perform a `POST` request (default is `GET`) + * `--conn-timeout CONN_TIMEOUT`: set the connection timeout + * `--timeout TIMEOUT`: set the total request timeout + * `--redirect-limit REDIRECT_LIMIT`: set the redirect limits + +## Example output + +### ./run-demo.py + +Performs a GET request using the reqwest backend + +``` +GET https://httpbin.org/anything (async) +got response +status: 200 +response: +{ + "args": {}, + "data": "", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Host": "httpbin.org", + "User-Agent": "fairy-bridge-demo", + "X-Amzn-Trace-Id": "Root=1-65848c2f-46df949b3229b84833aa445f", + "X-Foo": "bar" + }, + "json": null, + "method": "GET", + "origin": "8.9.85.40", + "url": "https://httpbin.org/anything" +} +``` + + +### ./run-demo.py --python --post --sync + +Perform a POST request using the Python backend in a non-async context. + +``` +POST https://httpbin.org/anything (sync) +got response +status: 200 +response: +{ + "args": {}, + "data": "{\"guid\":\"abcdef1234\",\"foo\":\"Bar\"}", + "files": {}, + "form": {}, + "headers": { + "Accept": "*/*", + "Accept-Encoding": "gzip, deflate", + "Content-Length": "33", + "Content-Type": "application/json", + "Host": "httpbin.org", + "User-Agent": "fairy-bridge-demo", + "X-Amzn-Trace-Id": "Root=1-65848ca7-2017c7cf112af7fa76c9c2e7", + "X-Foo": "bar" + }, + "json": { + "foo": "Bar", + "guid": "abcdef1234" + }, + "method": "POST", + "origin": "8.9.85.40", + "url": "https://httpbin.org/anything" +} +``` + +### ./run-demo.py --conn-timeout 0 + +Perform a GET request with a 0 ms timeout to force a failure + +``` +GET http://httpbin.org/anything (async) +error: BackendError(reqwest error: error sending request for url (http://httpbin.org/anything): error trying to connect: operation timed out) +``` diff --git a/examples/fairy-bridge-demo/run-demo.py b/examples/fairy-bridge-demo/run-demo.py new file mode 100755 index 0000000000..05a56242e1 --- /dev/null +++ b/examples/fairy-bridge-demo/run-demo.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import shutil +import sys +import subprocess + +crate_dir = Path(__file__).parent +root_dir = crate_dir.parent.parent +fairy_bridge_crate_dir = root_dir / 'components' / 'fairy-bridge' +target_debug = root_dir / 'target' / 'debug' +work_dir = root_dir / 'target' / 'fairy-bridge-demo' + +# build everything +def find_dylib(): + for prefix in ["lib", ""]: + for ext in ["so", "DLL", "dylib"]: + lib_path = target_debug / f"{prefix}fairy_bridge_demo.{ext}" + if lib_path.exists(): + return lib_path +if work_dir.exists(): + shutil.rmtree(work_dir) +work_dir.mkdir(parents=True) +subprocess.check_call(["cargo", "build"], cwd=crate_dir) +shutil.copy(crate_dir / "src" / "demo.py", work_dir) +dylib_path = find_dylib() +# TODO: make this less unix specific +subprocess.check_call([ + "g++", "--shared", "-fPIC", + "-lcurl", + "-I", fairy_bridge_crate_dir / "c-backend-include", + crate_dir / "src" / "fairy_bridge_backend.cpp", + dylib_path, + "-o", work_dir / "libfairy_bridge_demo.so" +]) +subprocess.check_call( + [ + "cargo", "run", "-p", "embedded-uniffi-bindgen", "--", "generate", "-l", "python", + "--library", dylib_path.absolute(), + "--out-dir", work_dir.absolute(), + ], + cwd=root_dir, +) + +# run it +print() +print() +subprocess.check_call( + ["/usr/bin/env", "python3", "demo.py"] + sys.argv[1:], + cwd = work_dir +) + diff --git a/examples/fairy-bridge-demo/src/demo.py b/examples/fairy-bridge-demo/src/demo.py new file mode 100644 index 0000000000..bf0bb6cf58 --- /dev/null +++ b/examples/fairy-bridge-demo/src/demo.py @@ -0,0 +1,97 @@ +import aiohttp +import argparse +import asyncio +import fairy_bridge +import fairy_bridge_demo + +parser = argparse.ArgumentParser() +backend = parser.add_mutually_exclusive_group() +backend.add_argument("--python", action="store_true") +backend.add_argument("-c", action="store_true") +parser.add_argument("--sync", action="store_true") +parser.add_argument("--post", action="store_true") +parser.add_argument("--conn-timeout", type=int) +parser.add_argument("--timeout", type=int) +parser.add_argument("--redirect-limit", type=int) +args = parser.parse_args() + +class PyBackend: + METHOD_MAP = { + fairy_bridge.Method.GET: "GET", + fairy_bridge.Method.HEAD: "HEAD", + fairy_bridge.Method.POST: "POST", + fairy_bridge.Method.PUT: "PUT", + fairy_bridge.Method.DELETE: "DELETE", + fairy_bridge.Method.CONNECT: "CONNECT", + fairy_bridge.Method.OPTIONS: "OPTIONS", + fairy_bridge.Method.TRACE: "TRACE", + fairy_bridge.Method.PATCH: "PATCH", + } + def __init__(self, settings: fairy_bridge.BackendSettings): + self.session_kwargs = dict( + timeout = aiohttp.ClientTimeout( + connect = PyBackend.convert_timeout(settings.connect_timeout), + total = PyBackend.convert_timeout(settings.timeout), + ) + ) + self.request_kwargs = dict( + allow_redirects = True if settings.redirect_limit > 0 else False, + max_redirects = settings.redirect_limit, + ) + + @staticmethod + def convert_timeout(settings_timeout): + return None if settings_timeout is None else settings_timeout / 1000.0 + + async def send_request(self, request: fairy_bridge.Request) -> fairy_bridge.Response: + async with aiohttp.ClientSession(**self.session_kwargs) as session: + method = self.METHOD_MAP[request.method] + url = request.url + kwargs = { + "headers": request.headers, + **self.request_kwargs + } + if request.body is not None: + kwargs["data"] = request.body + async with session.request(method, url, **kwargs) as response: + return fairy_bridge.Response( + url = str(response.url), + status = response.status, + headers = response.headers, + body = await response.read()) + +settings = fairy_bridge.BackendSettings() +if args.conn_timeout is not None: + settings.connect_timeout = args.conn_timeout +if args.timeout is not None: + settings.timeout = args.timeout +if args.redirect_limit is not None: + settings.redirect_limit = args.redirect_limit + +if args.python: + fairy_bridge.init_backend(PyBackend(settings)) +elif args.c: + fairy_bridge.init_backend_c(settings) +else: + fairy_bridge.init_backend_reqwest(settings) +# Always startup an event loop. Even if we're running in sync mode, `PyBackend` still needs it +# running. This mimics a typical app-services setup. Our component is sync, but the app that +# consumes it is running an async runtime. +async def run_demo(): + if args.sync: + loop = asyncio.get_running_loop() + # Call `uniffi_set_event_loop` so that it can run async code from the spawned thread. + # Note: this is only needed for Python. Both Swift and Kotlin have the concept of a global + # runtime. + fairy_bridge.uniffi_set_event_loop(loop) + # Run the sync code in an executor to avoid blocking the eventloop thread + if args.post: + await loop.run_in_executor(None, fairy_bridge_demo.run_demo_sync_post) + else: + await loop.run_in_executor(None, fairy_bridge_demo.run_demo_sync) + else: + if args.post: + await fairy_bridge_demo.run_demo_async_post() + else: + await fairy_bridge_demo.run_demo_async() +asyncio.run(run_demo()) diff --git a/examples/fairy-bridge-demo/src/fairy_bridge_backend.cpp b/examples/fairy-bridge-demo/src/fairy_bridge_backend.cpp new file mode 100644 index 0000000000..36e574071a --- /dev/null +++ b/examples/fairy-bridge-demo/src/fairy_bridge_backend.cpp @@ -0,0 +1,143 @@ +#include +#include +#include +#include "curl/curl.h" +#include "fairy_bridge.h" + +std::string copy_ffi_header(fairy_bridge::Header source); +void complete_error_with_c_string(fairy_bridge::Result* result, const char* message); +size_t header_callback(char *ptr, size_t size, size_t nmemb, fairy_bridge::Result* result); +size_t write_callback(char *ptr, size_t size, size_t nmemb, fairy_bridge::Result* result); +void request_thread(CURL* curl, fairy_bridge::Result* result); + +static fairy_bridge::BackendSettings settings; + +void fairy_bridge_backend_c_init(fairy_bridge::BackendSettings new_settings) { + settings = new_settings; +} + +void fairy_bridge_backend_c_send_request(fairy_bridge::Request* request, fairy_bridge::Result* result) { + curl_slist *headers = NULL; + + CURL* curl = curl_easy_init(); + if(!curl) { + complete_error_with_c_string(result, "Error initializing cURL"); + return; + } + + switch (request->method) { + case fairy_bridge::Method::Get: + break; + + case fairy_bridge::Method::Head: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "HEAD"); + break; + + case fairy_bridge::Method::Post: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "POST"); + break; + + case fairy_bridge::Method::Put: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PUT"); + break; + + case fairy_bridge::Method::Delete: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "DELETE"); + break; + + case fairy_bridge::Method::Connect: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "CONNECT"); + break; + + case fairy_bridge::Method::Options: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "OPTIONS"); + break; + + case fairy_bridge::Method::Trace: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "TRACE"); + break; + + case fairy_bridge::Method::Patch: + curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, "PATCH"); + break; + } + + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, settings.connect_timeout); + curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, settings.timeout); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, settings.redirect_limit); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1); + curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, header_callback); + curl_easy_setopt(curl, CURLOPT_HEADERDATA, result); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, result); + curl_easy_setopt(curl, CURLOPT_URL, request->url); + + for(int i = 0; i < request->header_count; i++) { + headers = curl_slist_append(headers, copy_ffi_header(request->headers[i]).c_str()); + } + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); + + // Spawn a thread to execute the request + // + // In a real-world C backend, we would probably use an existing threadpool rather than this. + auto perform_request = [=]() { + auto res = curl_easy_perform(curl); + if(res != CURLE_OK) { + complete_error_with_c_string(result, curl_easy_strerror(res)); + return; + } + + char* final_url = NULL; + curl_easy_getinfo(curl, CURLINFO_EFFECTIVE_URL, &final_url); + fairy_bridge_result_set_url(result, final_url, strlen(final_url)); + + long code; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &code); + fairy_bridge_result_set_status_code(result, code); + + curl_easy_cleanup(curl); + curl_slist_free_all(headers); + fairy_bridge_result_complete(result); + }; + std::thread request_thread(perform_request); + request_thread.detach(); +} + +std::string copy_ffi_header(fairy_bridge::Header source) { + std::string result; + result.append(source.key); + result.append(": "); + result.append(source.value); + return result; +} + +void complete_error_with_c_string(fairy_bridge::Result* result, const char* message) { + fairy_bridge_result_complete_error(result, message, strlen(message)); +} + +size_t write_callback(char *ptr, size_t size, size_t nmemb, fairy_bridge::Result *result) { + auto incoming_length = size * nmemb; + fairy_bridge_result_extend_body(result, ptr, incoming_length); + return incoming_length; +} + +size_t header_callback(char *ptr, size_t size, size_t nmemb, fairy_bridge::Result* result) { + auto incoming_length = size * nmemb; + size_t key_end, value_start, value_end; + for(key_end = 0; key_end < incoming_length; key_end++) { + if(ptr[key_end] == ':') { + break; + } + } + if(key_end == incoming_length) { + // Http status line, not a header. Skip it + return incoming_length; + } + for(value_start = key_end+1; value_start < incoming_length; value_start++) { + if(ptr[value_start] != ' ') break; + } + value_end = incoming_length-1; + + fairy_bridge_result_add_header(result, ptr, key_end, ptr + value_start, (value_end - value_start)); + return incoming_length; +} diff --git a/examples/fairy-bridge-demo/src/lib.rs b/examples/fairy-bridge-demo/src/lib.rs new file mode 100644 index 0000000000..a3feaf1d14 --- /dev/null +++ b/examples/fairy-bridge-demo/src/lib.rs @@ -0,0 +1,81 @@ +pub use fairy_bridge; + +use fairy_bridge::{headers, Request, Response}; +use url::Url; + +fn make_request() -> Request { + let url = Url::parse("http://httpbin.org/anything").unwrap(); + Request::get(url) + .header(headers::USER_AGENT, "fairy-bridge-demo") + .unwrap() + .header("X-Foo", "bar") + .unwrap() +} + +#[derive(serde::Serialize)] +struct TestPostData { + guid: String, + foo: String, +} + +fn make_post_request() -> Request { + let url = Url::parse("http://httpbin.org/anything").unwrap(); + Request::post(url) + .header(headers::USER_AGENT, "fairy-bridge-demo") + .unwrap() + .header("X-Foo", "bar") + .unwrap() + .json(&TestPostData { + guid: "abcdef1234".to_owned(), + foo: "Bar".to_owned(), + }) + .unwrap() +} + +#[uniffi::export] +async fn run_demo_async() { + println!("GET http://httpbin.org/anything (async)"); + let response = make_request().send().await; + print_response(response); +} + +#[uniffi::export] +fn run_demo_sync() { + println!("GET http://httpbin.org/anything (sync)"); + let response = make_request().send_sync(); + print_response(response); +} + +#[uniffi::export] +async fn run_demo_async_post() { + println!("POST http://httpbin.org/anything (async)"); + let response = make_post_request().send().await; + print_response(response); +} + +#[uniffi::export] +fn run_demo_sync_post() { + println!("POST http://httpbin.org/anything (sync)"); + let response = make_post_request().send_sync(); + print_response(response); +} + +fn print_response(response: fairy_bridge::Result) { + match response { + Ok(response) => { + println!("got response"); + println!("status: {}", response.status); + println!("\nHEADERS:"); + for (key, value) in response.headers { + println!("{}: {}", key, value); + } + println!("\nRESPONSE:"); + println!("{}", String::from_utf8(response.body).unwrap()); + } + Err(e) => { + println!("error: {e}"); + } + } +} + +uniffi::setup_scaffolding!();