From 7d9df7297a221a64d9de945ffc2cd8313d3104dc Mon Sep 17 00:00:00 2001 From: Amr Bashir Date: Mon, 7 Aug 2023 14:33:00 +0300 Subject: [PATCH] feat(http): refactor and improvements (#428) Co-authored-by: Lucas Nogueira --- .changes/http-multipart-refactor.md | 5 + .changes/http-plugin-refactor.md | 6 + Cargo.lock | 362 +++++++++++++++++- examples/api/src/views/Http.svelte | 74 ++-- plugins/http/Cargo.toml | 30 +- plugins/http/guest-js/index.ts | 560 ++++------------------------ plugins/http/src/api-iife.js | 2 +- plugins/http/src/commands.rs | 178 +++++++++ plugins/http/src/commands/client.rs | 341 ----------------- plugins/http/src/commands/mod.rs | 78 ---- plugins/http/src/config.rs | 5 +- plugins/http/src/error.rs | 35 +- plugins/http/src/lib.rs | 58 ++- plugins/http/src/scope.rs | 14 +- 14 files changed, 742 insertions(+), 1006 deletions(-) create mode 100644 .changes/http-multipart-refactor.md create mode 100644 .changes/http-plugin-refactor.md create mode 100644 plugins/http/src/commands.rs delete mode 100644 plugins/http/src/commands/client.rs delete mode 100644 plugins/http/src/commands/mod.rs diff --git a/.changes/http-multipart-refactor.md b/.changes/http-multipart-refactor.md new file mode 100644 index 0000000000..562943d5eb --- /dev/null +++ b/.changes/http-multipart-refactor.md @@ -0,0 +1,5 @@ +--- +"http-js": minor +--- + +Multipart requests are now handled in JavaScript by the `Request` JavaScript class so you just need to use a `FormData` body and not set the content-type header to `multipart/form-data`. `application/x-www-form-urlencoded` requests must be done manually. diff --git a/.changes/http-plugin-refactor.md b/.changes/http-plugin-refactor.md new file mode 100644 index 0000000000..ff089543f4 --- /dev/null +++ b/.changes/http-plugin-refactor.md @@ -0,0 +1,6 @@ +--- +"http": minor +"http-js": minor +--- + +The http plugin has been rewritten from scratch and now only exposes a `fetch` function in Javascript and Re-exports `reqwest` crate in Rust. The new `fetch` method tries to be as close and compliant to the `fetch` Web API as possible. diff --git a/Cargo.lock b/Cargo.lock index 92f85d3d78..60b0d73c32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,6 +325,20 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-compression" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b74f44609f0f91493e3082d3734d98497e094777144380ea4db9f9905dd5b6" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.5.1" @@ -367,7 +381,7 @@ dependencies = [ "polling", "rustix", "slab", - "socket2", + "socket2 0.4.9", "waker-fn", ] @@ -986,6 +1000,34 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time 0.3.21", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d606d0fba62e13cf04db20536c05cb7f13673c161cb47a47a82b9b9e7d3f1daa" +dependencies = [ + "cookie", + "idna 0.2.3", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time 0.3.21", + "url", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -1209,6 +1251,12 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2e66c9d817f1720209181c316d28635c050fa304f9c79e47a520882661b7308" +[[package]] +name = "data-url" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b319d1b62ffbd002e057f36bebd1f42b9f97927c9577461d855f3513c4289f" + [[package]] name = "der" version = "0.7.7" @@ -1400,6 +1448,18 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9720bba047d567ffc8a3cba48bf19126600e249ab7f128e9233e6376976a116" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "enumflags2" version = "0.7.7" @@ -1580,9 +1640,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c384f161156f5260c24a097c56119f9be8c798586aecc13afbcbe7b7e26bf8" +checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652" dependencies = [ "percent-encoding", ] @@ -2101,6 +2161,34 @@ dependencies = [ "tracing", ] +[[package]] +name = "h3" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6de6ca43eed186fd055214af06967b0a7a68336cefec7e8a4004e96efeaccb9e" +dependencies = [ + "bytes 1.4.0", + "fastrand", + "futures-util", + "http", + "tokio", + "tracing", +] + +[[package]] +name = "h3-quinn" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4a1a1763e4f3e82ee9f1ecf2cf862b22cc7316ebe14684e42f94532b5ec64d" +dependencies = [ + "bytes 1.4.0", + "futures", + "h3", + "quinn", + "quinn-proto", + "tokio-util", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2209,6 +2297,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "winapi", +] + [[package]] name = "html5ever" version = "0.25.2" @@ -2280,7 +2379,7 @@ dependencies = [ "httpdate", "itoa 1.0.6", "pin-project-lite", - "socket2", + "socket2 0.4.9", "tokio", "tower-service", "tracing", @@ -2362,6 +2461,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.3.0" @@ -2372,6 +2482,16 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "ignore" version = "0.4.20" @@ -2554,6 +2674,18 @@ dependencies = [ "libc", ] +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.3", + "widestring", + "windows-sys 0.48.0", + "winreg 0.50.0", +] + [[package]] name = "ipnet" version = "2.7.2" @@ -2832,6 +2964,12 @@ dependencies = [ "safemem", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.3.8" @@ -2873,6 +3011,15 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "mac" version = "0.1.1" @@ -2915,6 +3062,12 @@ dependencies = [ "tendril", ] +[[package]] +name = "match_cfg" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + [[package]] name = "matchers" version = "0.1.0" @@ -3525,9 +3678,9 @@ dependencies = [ [[package]] name = "percent-encoding" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" [[package]] name = "phf" @@ -3825,6 +3978,22 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96a8c1bda5ae1af7f99a2962e49df150414a43d62404644d98dd5c3a93d07457" +dependencies = [ + "idna 0.3.0", + "psl-types", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -3849,6 +4018,53 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cc2c5017e4b43d5995dcea317bc46c1e09404c0a9664d2908f7f02dfe943d75" +dependencies = [ + "bytes 1.4.0", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "quinn-proto" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8c8bb234e70c863204303507d841e7fa2295e95c822b2bb4ca8ebf57f17b1cb" +dependencies = [ + "bytes 1.4.0", + "rand 0.8.5", + "ring", + "rustc-hash", + "rustls", + "slab", + "thiserror", + "tinyvec", + "tracing", +] + +[[package]] +name = "quinn-udp" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df19e284d93757a9fb91d63672f7741b129246a669db09d1c0063071debc0c0" +dependencies = [ + "bytes 1.4.0", + "libc", + "socket2 0.5.3", + "tracing", + "windows-sys 0.48.0", +] + [[package]] name = "quote" version = "1.0.28" @@ -4023,12 +4239,18 @@ version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cde824a14b7c14f85caff81225f411faacc04a2013f41670f41443742b1c1c55" dependencies = [ + "async-compression", "base64 0.21.2", "bytes 1.4.0", + "cookie", + "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", + "h3", + "h3-quinn", "http", "http-body", "hyper", @@ -4043,7 +4265,9 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "quinn", "rustls", + "rustls-native-certs", "rustls-pemfile", "serde", "serde_json", @@ -4051,8 +4275,10 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-socks", "tokio-util", "tower-service", + "trust-dns-resolver", "url", "wasm-bindgen", "wasm-bindgen-futures", @@ -4062,6 +4288,16 @@ dependencies = [ "winreg 0.10.1", ] +[[package]] +name = "resolv-conf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" +dependencies = [ + "hostname", + "quick-error", +] + [[package]] name = "rfd" version = "0.11.4" @@ -4142,6 +4378,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.0" @@ -4177,6 +4419,18 @@ dependencies = [ "sct", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pemfile" version = "1.0.2" @@ -4548,6 +4802,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "socket2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877" +dependencies = [ + "libc", + "windows-sys 0.48.0", +] + [[package]] name = "soup3" version = "0.3.2" @@ -5322,17 +5586,16 @@ dependencies = [ name = "tauri-plugin-http" version = "2.0.0-alpha.0" dependencies = [ - "bytes 1.4.0", + "data-url", "glob", "http", - "rand 0.8.5", "reqwest", "serde", "serde_json", - "serde_repr", "tauri", "tauri-plugin-fs", "thiserror", + "url", ] [[package]] @@ -5844,7 +6107,7 @@ dependencies = [ "mio", "num_cpus", "pin-project-lite", - "socket2", + "socket2 0.4.9", "windows-sys 0.48.0", ] @@ -5868,6 +6131,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51165dfa029d2a65969413a6cc96f354b86b464498702f174a4efa13608fd8c0" +dependencies = [ + "either", + "futures-util", + "thiserror", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.14" @@ -6035,6 +6310,51 @@ dependencies = [ "serde_json", ] +[[package]] +name = "trust-dns-proto" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7f83d1e4a0e4358ac54c5c3681e5d7da5efc5a7a632c90bb6d6669ddd9bc26" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.2.3", + "ipnet", + "lazy_static", + "rand 0.8.5", + "smallvec", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aff21aa4dcefb0a1afbfac26deb0adc93888c7d295fb63ab273ef276ba2b7cfe" +dependencies = [ + "cfg-if", + "futures-util", + "ipconfig", + "lazy_static", + "lru-cache", + "parking_lot", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "tracing", + "trust-dns-proto", +] + [[package]] name = "try-lock" version = "0.2.4" @@ -6166,12 +6486,12 @@ checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" [[package]] name = "url" -version = "2.3.1" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d68c799ae75762b8c3fe375feb6600ef5602c883c5d21eb51c09f22b83c4643" +checksum = "50bff7831e19200a85b17131d085c25d7811bc4e186efdaf54bbd132994a88cb" dependencies = [ "form_urlencoded", - "idna", + "idna 0.4.0", "percent-encoding", "serde", ] @@ -6521,6 +6841,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c70234412ca409cc04e864e89523cb0fc37f5e1344ebed5a3ebf4192b6b9f68" +[[package]] +name = "widestring" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "653f141f39ec16bba3c5abe400a0c60da7468261cc2cbf36805022876bc721a8" + [[package]] name = "win7-notifications" version = "0.3.1" @@ -6921,6 +7247,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wry" version = "0.28.3" diff --git a/examples/api/src/views/Http.svelte b/examples/api/src/views/Http.svelte index 5a1d3032a7..842816b8df 100644 --- a/examples/api/src/views/Http.svelte +++ b/examples/api/src/views/Http.svelte @@ -1,5 +1,5 @@ @@ -87,11 +96,6 @@
- -


diff --git a/plugins/http/Cargo.toml b/plugins/http/Cargo.toml index f1fb60826b..95f74bfe2d 100644 --- a/plugins/http/Cargo.toml +++ b/plugins/http/Cargo.toml @@ -13,14 +13,28 @@ tauri = { workspace = true } thiserror = { workspace = true } tauri-plugin-fs = { path = "../fs", version = "2.0.0-alpha.0" } glob = "0.3" -rand = "0.8" -bytes = { version = "1", features = [ "serde" ] } -serde_repr = "0.1" http = "0.2" -reqwest = { version = "0.11", default-features = false, features = [ "json", "stream" ] } +reqwest = { version = "0.11", default-features = false } +url = "2.4" +data-url = "0.3" [features] -multipart = [ "reqwest/multipart" ] -native-tls = [ "reqwest/native-tls" ] -native-tls-vendored = [ "reqwest/native-tls-vendored" ] -rustls-tls = [ "reqwest/rustls-tls" ] +multipart = ["reqwest/multipart"] +json = ["reqwest/json"] +stream = ["reqwest/stream"] +native-tls = ["reqwest/native-tls"] +native-tls-vendored = ["reqwest/native-tls-vendored"] +rustls-tls = ["reqwest/rustls-tls"] +default-tls = ["reqwest/default-tls"] +native-tls-alpn = ["reqwest/native-tls-alpn"] +rustls-tls-manual-roots = ["reqwest/rustls-tls-manual-roots"] +rustls-tls-webpki-roots = ["reqwest/rustls-tls-webpki-roots"] +rustls-tls-native-roots = ["reqwest/rustls-tls-native-roots"] +blocking = ["reqwest/blocking"] +cookies = ["reqwest/cookies"] +gzip = ["reqwest/gzip"] +brotli = ["reqwest/brotli"] +deflate = ["reqwest/deflate"] +trust-dns = ["reqwest/trust-dns"] +socks = ["reqwest/socks"] +http3 = ["reqwest/http3"] diff --git a/plugins/http/guest-js/index.ts b/plugins/http/guest-js/index.ts index ecc1745363..e991076f10 100644 --- a/plugins/http/guest-js/index.ts +++ b/plugins/http/guest-js/index.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT /** - * Access the HTTP client written in Rust. + * Make HTTP requests with the Rust backend. * * ## Security * @@ -31,518 +31,94 @@ declare global { } /** + * Options to configure the Rust client used to make fetch requests + * * @since 2.0.0 */ -interface Duration { - secs: number; - nanos: number; -} - -/** - * @since 2.0.0 - */ -interface ClientOptions { +export interface ClientOptions { /** * Defines the maximum number of redirects the client should follow. * If set to 0, no redirects will be followed. */ maxRedirections?: number; - connectTimeout?: number | Duration; + /** Timeout in milliseconds */ + connectTimeout?: number; } /** - * @since 2.0.0 - */ -enum ResponseType { - JSON = 1, - Text = 2, - Binary = 3, -} - -/** - * @since 2.0.0 - */ -interface FilePart { - file: string | T; - mime?: string; - fileName?: string; -} - -type Part = string | Uint8Array | FilePart; - -/** - * The body object to be used on POST and PUT requests. + * Fetch a resource from the network. It returns a `Promise` that resolves to the + * `Response` to that `Request`, whether it is successful or not. * - * @since 2.0.0 - */ -class Body { - type: string; - payload: unknown; - - /** @ignore */ - private constructor(type: string, payload: unknown) { - this.type = type; - this.payload = payload; - } - - /** - * Creates a new form data body. The form data is an object where each key is the entry name, - * and the value is either a string or a file object. - * - * By default it sets the `application/x-www-form-urlencoded` Content-Type header, - * but you can set it to `multipart/form-data` if the Cargo feature `multipart` is enabled. - * - * Note that a file path must be allowed in the `fs` scope. - * @example - * ```typescript - * import { Body } from "@tauri-apps/plugin-http" - * const body = Body.form({ - * key: 'value', - * image: { - * file: '/path/to/file', // either a path or an array buffer of the file contents - * mime: 'image/jpeg', // optional - * fileName: 'image.jpg' // optional - * } - * }); - * - * // alternatively, use a FormData: - * const form = new FormData(); - * form.append('key', 'value'); - * form.append('image', file, 'image.png'); - * const formBody = Body.form(form); - * ``` - * - * @param data The body data. - * - * @returns The body object ready to be used on the POST and PUT requests. - * - * @since 2.0.0 - */ - static form(data: Record | FormData): Body { - const form: Record> = {}; - - const append = ( - key: string, - v: string | Uint8Array | FilePart | File, - ): void => { - if (v !== null) { - let r; - if (typeof v === "string") { - r = v; - } else if (v instanceof Uint8Array || Array.isArray(v)) { - r = Array.from(v); - } else if (v instanceof File) { - r = { file: v.name, mime: v.type, fileName: v.name }; - } else if (typeof v.file === "string") { - r = { file: v.file, mime: v.mime, fileName: v.fileName }; - } else { - r = { file: Array.from(v.file), mime: v.mime, fileName: v.fileName }; - } - form[String(key)] = r; - } - }; - - if (data instanceof FormData) { - for (const [key, value] of data) { - append(key, value); - } - } else { - for (const [key, value] of Object.entries(data)) { - append(key, value); - } - } - return new Body("Form", form); - } - - /** - * Creates a new JSON body. - * @example - * ```typescript - * import { Body } from "@tauri-apps/plugin-http" - * Body.json({ - * registered: true, - * name: 'tauri' - * }); - * ``` - * - * @param data The body JSON object. - * - * @returns The body object ready to be used on the POST and PUT requests. - * - * @since 2.0.0 - */ - static json(data: Record): Body { - return new Body("Json", data); - } - - /** - * Creates a new UTF-8 string body. - * @example - * ```typescript - * import { Body } from "@tauri-apps/plugin-http" - * Body.text('The body content as a string'); - * ``` - * - * @param value The body string. - * - * @returns The body object ready to be used on the POST and PUT requests. - * - * @since 2.0.0 - */ - static text(value: string): Body { - return new Body("Text", value); - } - - /** - * Creates a new byte array body. - * @example - * ```typescript - * import { Body } from "@tauri-apps/plugin-http" - * Body.bytes(new Uint8Array([1, 2, 3])); - * ``` - * - * @param bytes The body byte array. - * - * @returns The body object ready to be used on the POST and PUT requests. - * - * @since 2.0.0 - */ - static bytes( - bytes: Iterable | ArrayLike | ArrayBuffer, - ): Body { - // stringifying Uint8Array doesn't return an array of numbers, so we create one here - return new Body( - "Bytes", - Array.from(bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes), - ); - } -} - -/** The request HTTP verb. */ -type HttpVerb = - | "GET" - | "POST" - | "PUT" - | "DELETE" - | "PATCH" - | "HEAD" - | "OPTIONS" - | "CONNECT" - | "TRACE"; - -/** - * Options object sent to the backend. + * @example + * ```typescript + * const response = await fetch("http://my.json.host/data.json"); + * console.log(response.status); // e.g. 200 + * console.log(response.statusText); // e.g. "OK" + * const jsonData = await response.json(); + * ``` * * @since 2.0.0 */ -interface HttpOptions { - method: HttpVerb; - url: string; - headers?: Record; - query?: Record; - body?: Body; - timeout?: number | Duration; - responseType?: ResponseType; -} - -/** Request options. */ -type RequestOptions = Omit; -/** Options for the `fetch` API. */ -type FetchOptions = Omit; - -/** @ignore */ -interface IResponse { - url: string; - status: number; - headers: Record; - rawHeaders: Record; - data: T; -} - -/** - * Response object. - * - * @since 2.0.0 - * */ -class Response { - /** The request URL. */ - url: string; - /** The response status code. */ - status: number; - /** A boolean indicating whether the response was successful (status in the range 200–299) or not. */ - ok: boolean; - /** The response headers. */ - headers: Record; - /** The response raw headers. */ - rawHeaders: Record; - /** The response data. */ - data: T; - - /** @ignore */ - constructor(response: IResponse) { - this.url = response.url; - this.status = response.status; - this.ok = this.status >= 200 && this.status < 300; - this.headers = response.headers; - this.rawHeaders = response.rawHeaders; - this.data = response.data; - } -} - -/** - * @since 2.0.0 - */ -class Client { - id: number; - /** @ignore */ - constructor(id: number) { - this.id = id; - } - - /** - * Drops the client instance. - * @example - * ```typescript - * import { getClient } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * await client.drop(); - * ``` - */ - async drop(): Promise { - return window.__TAURI_INVOKE__("plugin:http|drop_client", { - client: this.id, - }); - } - - /** - * Makes an HTTP request. - * @example - * ```typescript - * import { getClient } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * const response = await client.request({ - * method: 'GET', - * url: 'http://localhost:3003/users', - * }); - * ``` - */ - async request(options: HttpOptions): Promise> { - const jsonResponse = - !options.responseType || options.responseType === ResponseType.JSON; - if (jsonResponse) { - options.responseType = ResponseType.Text; - } - return window - .__TAURI_INVOKE__>("plugin:http|request", { - clientId: this.id, - options, - }) - .then((res) => { - const response = new Response(res); - if (jsonResponse) { - /* eslint-disable */ - try { - response.data = JSON.parse(response.data as string); - } catch (e) { - if (response.ok && (response.data as unknown as string) === "") { - response.data = {} as T; - } else if (response.ok) { - throw Error( - `Failed to parse response \`${response.data}\` as JSON: ${e}; - try setting the \`responseType\` option to \`ResponseType.Text\` or \`ResponseType.Binary\` if the API does not return a JSON response.`, - ); - } - } - /* eslint-enable */ - return response; - } - return response; - }); - } - - /** - * Makes a GET request. - * @example - * ```typescript - * import { getClient, ResponseType } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * const response = await client.get('http://localhost:3003/users', { - * timeout: 30, - * // the expected response type - * responseType: ResponseType.JSON - * }); - * ``` - */ - async get(url: string, options?: RequestOptions): Promise> { - return this.request({ - method: "GET", - url, - ...options, - }); - } - - /** - * Makes a POST request. - * @example - * ```typescript - * import { getClient, Body, ResponseType } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * const response = await client.post('http://localhost:3003/users', { - * body: Body.json({ - * name: 'tauri', - * password: 'awesome' - * }), - * // in this case the server returns a simple string - * responseType: ResponseType.Text, - * }); - * ``` - */ - async post( - url: string, - body?: Body, - options?: RequestOptions, - ): Promise> { - return this.request({ - method: "POST", - url, - body, - ...options, - }); - } +export async function fetch( + input: URL | Request | string, + init?: RequestInit & ClientOptions, +): Promise { + const maxRedirections = init?.maxRedirections; + const connectTimeout = init?.maxRedirections; + + // Remove these fields before creating the request + if (init) { + delete init.maxRedirections; + delete init.connectTimeout; + } + + const req = new Request(input, init); + const buffer = await req.arrayBuffer(); + const reqData = buffer.byteLength ? Array.from(new Uint8Array(buffer)) : null; + + const rid = await window.__TAURI_INVOKE__("plugin:http|fetch", { + cmd: "fetch", + method: req.method, + url: req.url, + headers: Array.from(req.headers.entries()), + data: reqData, + maxRedirections, + connectTimeout, + }); - /** - * Makes a PUT request. - * @example - * ```typescript - * import { getClient, Body } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * const response = await client.put('http://localhost:3003/users/1', { - * body: Body.form({ - * file: { - * file: '/home/tauri/avatar.png', - * mime: 'image/png', - * fileName: 'avatar.png' - * } - * }) - * }); - * ``` - */ - async put( - url: string, - body?: Body, - options?: RequestOptions, - ): Promise> { - return this.request({ - method: "PUT", - url, - body, - ...options, + req.signal.addEventListener("abort", () => { + window.__TAURI_INVOKE__("plugin:http|fetch_cancel", { + rid, }); - } + }); - /** - * Makes a PATCH request. - * @example - * ```typescript - * import { getClient, Body } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * const response = await client.patch('http://localhost:3003/users/1', { - * body: Body.json({ email: 'contact@tauri.app' }) - * }); - * ``` - */ - async patch(url: string, options?: RequestOptions): Promise> { - return this.request({ - method: "PATCH", - url, - ...options, - }); + interface FetchSendResponse { + status: number; + statusText: string; + headers: [[string, string]]; + url: string; } - /** - * Makes a DELETE request. - * @example - * ```typescript - * import { getClient } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * const response = await client.delete('http://localhost:3003/users/1'); - * ``` - */ - async delete(url: string, options?: RequestOptions): Promise> { - return this.request({ - method: "DELETE", - url, - ...options, + const { status, statusText, url, headers } = + await window.__TAURI_INVOKE__("plugin:http|fetch_send", { + rid, }); - } -} - -/** - * Creates a new client using the specified options. - * @example - * ```typescript - * import { getClient } from '@tauri-apps/plugin-http'; - * const client = await getClient(); - * ``` - * - * @param options Client configuration. - * - * @returns A promise resolving to the client instance. - * - * @since 2.0.0 - */ -async function getClient(options?: ClientOptions): Promise { - return window - .__TAURI_INVOKE__("plugin:http|create_client", { - options, - }) - .then((id) => new Client(id)); -} - -/** @internal */ -let defaultClient: Client | null = null; -/** - * Perform an HTTP request using the default client. - * @example - * ```typescript - * import { fetch } from '@tauri-apps/plugin-http'; - * const response = await fetch('http://localhost:3003/users/2', { - * method: 'GET', - * timeout: 30, - * }); - * ``` - */ -async function fetch( - url: string, - options?: FetchOptions, -): Promise> { - if (defaultClient === null) { - defaultClient = await getClient(); - } - return defaultClient.request({ - url, - method: options?.method ?? "GET", - ...options, + const body = await window.__TAURI_INVOKE__( + "plugin:http|fetch_read_body", + { + rid, + }, + ); + + const res = new Response(Uint8Array.from(body), { + headers, + status, + statusText, }); -} -export type { - Duration, - ClientOptions, - Part, - HttpVerb, - HttpOptions, - RequestOptions, - FetchOptions, -}; + // url is read only but seems like we can do this + Object.defineProperty(res, "url", { value: url }); -export { - getClient, - fetch, - Body, - Client, - Response, - ResponseType, - type FilePart, -}; + return res; +} diff --git a/plugins/http/src/api-iife.js b/plugins/http/src/api-iife.js index e99aa35b8e..18db71722c 100644 --- a/plugins/http/src/api-iife.js +++ b/plugins/http/src/api-iife.js @@ -1 +1 @@ -if("__TAURI__"in window){var __TAURI_HTTP__=function(e){"use strict";var t;e.ResponseType=void 0,(t=e.ResponseType||(e.ResponseType={}))[t.JSON=1]="JSON",t[t.Text=2]="Text",t[t.Binary=3]="Binary";class r{constructor(e,t){this.type=e,this.payload=t}static form(e){const t={},s=(e,r)=>{if(null!==r){let s;s="string"==typeof r?r:r instanceof Uint8Array||Array.isArray(r)?Array.from(r):r instanceof File?{file:r.name,mime:r.type,fileName:r.name}:"string"==typeof r.file?{file:r.file,mime:r.mime,fileName:r.fileName}:{file:Array.from(r.file),mime:r.mime,fileName:r.fileName},t[String(e)]=s}};if(e instanceof FormData)for(const[t,r]of e)s(t,r);else for(const[t,r]of Object.entries(e))s(t,r);return new r("Form",t)}static json(e){return new r("Json",e)}static text(e){return new r("Text",e)}static bytes(e){return new r("Bytes",Array.from(e instanceof ArrayBuffer?new Uint8Array(e):e))}}class s{constructor(e){this.url=e.url,this.status=e.status,this.ok=this.status>=200&&this.status<300,this.headers=e.headers,this.rawHeaders=e.rawHeaders,this.data=e.data}}class n{constructor(e){this.id=e}async drop(){return window.__TAURI_INVOKE__("plugin:http|drop_client",{client:this.id})}async request(t){const r=!t.responseType||t.responseType===e.ResponseType.JSON;return r&&(t.responseType=e.ResponseType.Text),window.__TAURI_INVOKE__("plugin:http|request",{clientId:this.id,options:t}).then((e=>{const t=new s(e);if(r){try{t.data=JSON.parse(t.data)}catch(e){if(t.ok&&""===t.data)t.data={};else if(t.ok)throw Error(`Failed to parse response \`${t.data}\` as JSON: ${e};\n try setting the \`responseType\` option to \`ResponseType.Text\` or \`ResponseType.Binary\` if the API does not return a JSON response.`)}return t}return t}))}async get(e,t){return this.request({method:"GET",url:e,...t})}async post(e,t,r){return this.request({method:"POST",url:e,body:t,...r})}async put(e,t,r){return this.request({method:"PUT",url:e,body:t,...r})}async patch(e,t){return this.request({method:"PATCH",url:e,...t})}async delete(e,t){return this.request({method:"DELETE",url:e,...t})}}async function i(e){return window.__TAURI_INVOKE__("plugin:http|create_client",{options:e}).then((e=>new n(e)))}let o=null;return e.Body=r,e.Client=n,e.Response=s,e.fetch=async function(e,t){var r;return null===o&&(o=await i()),o.request({url:e,method:null!==(r=null==t?void 0:t.method)&&void 0!==r?r:"GET",...t})},e.getClient=i,e}({});Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_HTTP__})} +if("__TAURI__"in window){var __TAURI_HTTP__=function(e){"use strict";return e.fetch=async function(e,t){const n=null==t?void 0:t.maxRedirections,r=null==t?void 0:t.maxRedirections;t&&(delete t.maxRedirections,delete t.connectTimeout);const _=new Request(e,t),i=await _.arrayBuffer(),a=i.byteLength?Array.from(new Uint8Array(i)):null,d=await window.__TAURI_INVOKE__("plugin:http|fetch",{cmd:"fetch",method:_.method,url:_.url,headers:Array.from(_.headers.entries()),data:a,maxRedirections:n,connectTimeout:r});_.signal.addEventListener("abort",(()=>{window.__TAURI_INVOKE__("plugin:http|fetch_cancel",{rid:d})}));const{status:o,statusText:s,url:c,headers:u}=await window.__TAURI_INVOKE__("plugin:http|fetch_send",{rid:d}),l=await window.__TAURI_INVOKE__("plugin:http|fetch_read_body",{rid:d}),w=new Response(Uint8Array.from(l),{headers:u,status:o,statusText:s});return Object.defineProperty(w,"url",{value:c}),w},e}({});Object.defineProperty(window.__TAURI__,"http",{value:__TAURI_HTTP__})} diff --git a/plugins/http/src/commands.rs b/plugins/http/src/commands.rs new file mode 100644 index 0000000000..833b4e7f04 --- /dev/null +++ b/plugins/http/src/commands.rs @@ -0,0 +1,178 @@ +// Copyright 2019-2023 Tauri Programme within The Commons Conservancy +// SPDX-License-Identifier: Apache-2.0 +// SPDX-License-Identifier: MIT + +use std::{collections::HashMap, time::Duration}; + +use http::{header, HeaderName, HeaderValue, Method, StatusCode}; +use reqwest::redirect::Policy; +use serde::Serialize; +use tauri::{command, AppHandle, Runtime}; + +use crate::{Error, FetchRequest, HttpExt, RequestId}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FetchResponse { + status: u16, + status_text: String, + headers: Vec<(String, String)>, + url: String, +} + +#[command] +pub async fn fetch( + app: AppHandle, + method: String, + url: url::Url, + headers: Vec<(String, String)>, + data: Option>, + connect_timeout: Option, + max_redirections: Option, +) -> crate::Result { + let scheme = url.scheme(); + let method = Method::from_bytes(method.as_bytes())?; + let headers: HashMap = HashMap::from_iter(headers); + + match scheme { + "http" | "https" => { + if app.http().scope.is_allowed(&url) { + let mut builder = reqwest::ClientBuilder::new(); + + if let Some(timeout) = connect_timeout { + builder = builder.connect_timeout(Duration::from_millis(timeout)); + } + + if let Some(max_redirections) = max_redirections { + builder = builder.redirect(if max_redirections == 0 { + Policy::none() + } else { + Policy::limited(max_redirections) + }); + } + + let mut request = builder.build()?.request(method.clone(), url); + + for (key, value) in &headers { + let name = HeaderName::from_bytes(key.as_bytes())?; + let v = HeaderValue::from_bytes(value.as_bytes())?; + if !matches!(name, header::HOST | header::CONTENT_LENGTH) { + request = request.header(name, v); + } + } + + // POST and PUT requests should always have a 0 length content-length, + // if there is no body. https://fetch.spec.whatwg.org/#http-network-or-cache-fetch + if data.is_none() && matches!(method, Method::POST | Method::PUT) { + request = request.header(header::CONTENT_LENGTH, HeaderValue::from(0)); + } + + if headers.contains_key(header::RANGE.as_str()) { + // https://fetch.spec.whatwg.org/#http-network-or-cache-fetch step 18 + // If httpRequest’s header list contains `Range`, then append (`Accept-Encoding`, `identity`) + request = request.header( + header::ACCEPT_ENCODING, + HeaderValue::from_static("identity"), + ); + } + + if !headers.contains_key(header::USER_AGENT.as_str()) { + request = request.header(header::USER_AGENT, HeaderValue::from_static("tauri")); + } + + if let Some(data) = data { + request = request.body(data); + } + + let http_state = app.http(); + let rid = http_state.next_id(); + let fut = async move { Ok(request.send().await.map_err(Into::into)) }; + let mut request_table = http_state.requests.lock().await; + request_table.insert(rid, FetchRequest::new(Box::pin(fut))); + + Ok(rid) + } else { + Err(Error::UrlNotAllowed(url)) + } + } + "data" => { + let data_url = + data_url::DataUrl::process(url.as_str()).map_err(|_| Error::DataUrlError)?; + let (body, _) = data_url + .decode_to_vec() + .map_err(|_| Error::DataUrlDecodeError)?; + + let response = http::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, data_url.mime_type().to_string()) + .body(reqwest::Body::from(body))?; + + let http_state = app.http(); + let rid = http_state.next_id(); + let fut = async move { Ok(Ok(reqwest::Response::from(response))) }; + let mut request_table = http_state.requests.lock().await; + request_table.insert(rid, FetchRequest::new(Box::pin(fut))); + Ok(rid) + } + _ => Err(Error::SchemeNotSupport(scheme.to_string())), + } +} + +#[command] +pub async fn fetch_cancel(app: AppHandle, rid: RequestId) -> crate::Result<()> { + let mut request_table = app.http().requests.lock().await; + let req = request_table + .get_mut(&rid) + .ok_or(Error::InvalidRequestId(rid))?; + *req = FetchRequest::new(Box::pin(async { Err(Error::RequestCanceled) })); + Ok(()) +} + +#[command] +pub async fn fetch_send( + app: AppHandle, + rid: RequestId, +) -> crate::Result { + let mut request_table = app.http().requests.lock().await; + let req = request_table + .remove(&rid) + .ok_or(Error::InvalidRequestId(rid))?; + + let res = match req.0.lock().await.as_mut().await { + Ok(Ok(res)) => res, + Ok(Err(e)) | Err(e) => return Err(e), + }; + + let status = res.status(); + let url = res.url().to_string(); + let mut headers = Vec::new(); + for (key, val) in res.headers().iter() { + headers.push(( + key.as_str().into(), + String::from_utf8(val.as_bytes().to_vec())?, + )); + } + + app.http().responses.lock().await.insert(rid, res); + + Ok(FetchResponse { + status: status.as_u16(), + status_text: status.canonical_reason().unwrap_or_default().to_string(), + headers, + url, + }) +} + +// TODO: change return value to tauri::ipc::Response on next alpha +#[command] +pub(crate) async fn fetch_read_body( + app: AppHandle, + rid: RequestId, +) -> crate::Result> { + let mut response_table = app.http().responses.lock().await; + let res = response_table + .remove(&rid) + .ok_or(Error::InvalidRequestId(rid))?; + + Ok(res.bytes().await?.to_vec()) +} diff --git a/plugins/http/src/commands/client.rs b/plugins/http/src/commands/client.rs deleted file mode 100644 index 07614a53d2..0000000000 --- a/plugins/http/src/commands/client.rs +++ /dev/null @@ -1,341 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use std::{collections::HashMap, path::PathBuf, time::Duration}; - -use reqwest::{header, Method, Url}; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::Value; -use serde_repr::{Deserialize_repr, Serialize_repr}; - -#[derive(Deserialize)] -#[serde(untagged)] -enum SerdeDuration { - Seconds(u64), - Duration(Duration), -} - -fn deserialize_duration<'de, D: Deserializer<'de>>( - deserializer: D, -) -> std::result::Result, D::Error> { - if let Some(duration) = Option::::deserialize(deserializer)? { - Ok(Some(match duration { - SerdeDuration::Seconds(s) => Duration::from_secs(s), - SerdeDuration::Duration(d) => d, - })) - } else { - Ok(None) - } -} - -/// The builder of [`Client`]. -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ClientBuilder { - /// Max number of redirections to follow. - pub max_redirections: Option, - /// Connect timeout for the request. - #[serde(deserialize_with = "deserialize_duration", default)] - pub connect_timeout: Option, -} - -impl ClientBuilder { - /// Builds the Client. - pub fn build(self) -> crate::Result { - let mut client_builder = reqwest::Client::builder(); - - if let Some(max_redirections) = self.max_redirections { - client_builder = client_builder.redirect(if max_redirections == 0 { - reqwest::redirect::Policy::none() - } else { - reqwest::redirect::Policy::limited(max_redirections) - }); - } - - if let Some(connect_timeout) = self.connect_timeout { - client_builder = client_builder.connect_timeout(connect_timeout); - } - - let client = client_builder.build()?; - Ok(Client(client)) - } -} - -/// The HTTP client based on [`reqwest`]. -#[derive(Debug, Clone)] -pub struct Client(reqwest::Client); - -impl Client { - /// Executes an HTTP request - /// - /// # Examples - pub async fn send(&self, mut request: HttpRequestBuilder) -> crate::Result { - let method = Method::from_bytes(request.method.to_uppercase().as_bytes())?; - - let mut request_builder = self.0.request(method, request.url.as_str()); - - if let Some(query) = request.query { - request_builder = request_builder.query(&query); - } - - if let Some(timeout) = request.timeout { - request_builder = request_builder.timeout(timeout); - } - - if let Some(body) = request.body { - request_builder = match body { - Body::Bytes(data) => request_builder.body(bytes::Bytes::from(data)), - Body::Text(text) => request_builder.body(bytes::Bytes::from(text)), - Body::Json(json) => request_builder.json(&json), - Body::Form(form_body) => { - #[allow(unused_variables)] - fn send_form( - request_builder: reqwest::RequestBuilder, - headers: &mut Option, - form_body: FormBody, - ) -> crate::Result { - #[cfg(feature = "multipart")] - if matches!( - headers - .as_ref() - .and_then(|h| h.0.get("content-type")) - .map(|v| v.as_bytes()), - Some(b"multipart/form-data") - ) { - // the Content-Type header will be set by reqwest in the `.multipart` call - headers.as_mut().map(|h| h.0.remove("content-type")); - let mut multipart = reqwest::multipart::Form::new(); - - for (name, part) in form_body.0 { - let part = match part { - FormPart::File { - file, - mime, - file_name, - } => { - let bytes: Vec = file.try_into()?; - let mut part = reqwest::multipart::Part::bytes(bytes); - if let Some(mime) = mime { - part = part.mime_str(&mime)?; - } - if let Some(file_name) = file_name { - part = part.file_name(file_name); - } - part - } - FormPart::Text(value) => reqwest::multipart::Part::text(value), - }; - - multipart = multipart.part(name, part); - } - - return Ok(request_builder.multipart(multipart)); - } - - let mut form = Vec::new(); - for (name, part) in form_body.0 { - match part { - FormPart::File { file, .. } => { - let bytes: Vec = file.try_into()?; - form.push((name, serde_json::to_string(&bytes)?)) - } - FormPart::Text(value) => form.push((name, value)), - } - } - Ok(request_builder.form(&form)) - } - send_form(request_builder, &mut request.headers, form_body)? - } - }; - } - - if let Some(headers) = request.headers { - request_builder = request_builder.headers(headers.0); - } - - let http_request = request_builder.build()?; - - let response = self.0.execute(http_request).await?; - - Ok(Response( - request.response_type.unwrap_or(ResponseType::Json), - response, - )) - } -} - -#[derive(Serialize_repr, Deserialize_repr, Clone, Debug)] -#[repr(u16)] -#[non_exhaustive] -/// The HTTP response type. -pub enum ResponseType { - /// Read the response as JSON - Json = 1, - /// Read the response as text - Text, - /// Read the response as binary - Binary, -} - -#[derive(Debug)] -pub struct Response(ResponseType, reqwest::Response); - -impl Response { - /// Reads the response. - /// - /// Note that the body is serialized to a [`Value`]. - pub async fn read(self) -> crate::Result { - let url = self.1.url().clone(); - - let mut headers = HashMap::new(); - let mut raw_headers = HashMap::new(); - for (name, value) in self.1.headers() { - headers.insert( - name.as_str().to_string(), - String::from_utf8(value.as_bytes().to_vec())?, - ); - raw_headers.insert( - name.as_str().to_string(), - self.1 - .headers() - .get_all(name) - .into_iter() - .map(|v| String::from_utf8(v.as_bytes().to_vec()).map_err(Into::into)) - .collect::>>()?, - ); - } - let status = self.1.status().as_u16(); - - let data = match self.0 { - ResponseType::Json => self.1.json().await?, - ResponseType::Text => Value::String(self.1.text().await?), - ResponseType::Binary => serde_json::to_value(&self.1.bytes().await?)?, - }; - - Ok(ResponseData { - url, - status, - headers, - raw_headers, - data, - }) - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -#[non_exhaustive] -pub struct ResponseData { - /// Response URL. Useful if it followed redirects. - pub url: Url, - /// Response status code. - pub status: u16, - /// Response headers. - pub headers: HashMap, - /// Response raw headers. - pub raw_headers: HashMap>, - /// Response data. - pub data: Value, -} - -/// A file path or contents. -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -#[non_exhaustive] -pub enum FilePart { - /// File path. - Path(PathBuf), - /// File contents. - Contents(Vec), -} - -impl TryFrom for Vec { - type Error = crate::Error; - fn try_from(file: FilePart) -> crate::Result { - let bytes = match file { - FilePart::Path(path) => std::fs::read(path)?, - FilePart::Contents(bytes) => bytes, - }; - Ok(bytes) - } -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -#[non_exhaustive] -pub enum FormPart { - /// A string value. - Text(String), - /// A file value. - #[serde(rename_all = "camelCase")] - File { - /// File path or content. - file: FilePart, - /// Mime type of this part. - /// Only used when the `Content-Type` header is set to `multipart/form-data`. - mime: Option, - /// File name. - /// Only used when the `Content-Type` header is set to `multipart/form-data`. - file_name: Option, - }, -} - -#[derive(Debug, Deserialize)] -pub struct FormBody(pub(crate) HashMap); - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", content = "payload")] -#[non_exhaustive] -pub enum Body { - Form(FormBody), - Json(Value), - Text(String), - Bytes(Vec), -} - -#[derive(Debug, Default)] -pub struct HeaderMap(header::HeaderMap); - -impl<'de> Deserialize<'de> for HeaderMap { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let map = HashMap::::deserialize(deserializer)?; - let mut headers = header::HeaderMap::default(); - for (key, value) in map { - if let (Ok(key), Ok(value)) = ( - header::HeaderName::from_bytes(key.as_bytes()), - header::HeaderValue::from_str(&value), - ) { - headers.insert(key, value); - } else { - return Err(serde::de::Error::custom(format!( - "invalid header `{key}` `{value}`" - ))); - } - } - Ok(Self(headers)) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HttpRequestBuilder { - /// The request method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS, CONNECT or TRACE) - pub method: String, - /// The request URL - pub url: Url, - /// The request query params - pub query: Option>, - /// The request headers - pub headers: Option, - /// The request body - pub body: Option, - /// Timeout for the whole request - #[serde(deserialize_with = "deserialize_duration", default)] - pub timeout: Option, - /// The response type (defaults to Json) - pub response_type: Option, -} diff --git a/plugins/http/src/commands/mod.rs b/plugins/http/src/commands/mod.rs deleted file mode 100644 index 94c7132489..0000000000 --- a/plugins/http/src/commands/mod.rs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright 2019-2023 Tauri Programme within The Commons Conservancy -// SPDX-License-Identifier: Apache-2.0 -// SPDX-License-Identifier: MIT - -use tauri::{path::SafePathBuf, AppHandle, Runtime, State}; -use tauri_plugin_fs::FsExt; - -use crate::{ClientId, Http}; - -mod client; -use client::{Body, ClientBuilder, FilePart, FormPart, HttpRequestBuilder, ResponseData}; - -pub use client::Client; - -#[tauri::command] -pub async fn create_client( - _app: AppHandle, - http: State<'_, Http>, - options: Option, -) -> super::Result { - let client = options.unwrap_or_default().build()?; - let mut store = http.clients.lock().unwrap(); - let id = rand::random::(); - store.insert(id, client); - Ok(id) -} - -#[tauri::command] -pub async fn drop_client( - _app: AppHandle, - http: State<'_, Http>, - client: ClientId, -) -> super::Result<()> { - let mut store = http.clients.lock().unwrap(); - store.remove(&client); - Ok(()) -} - -#[tauri::command] -pub async fn request( - app: AppHandle, - http: State<'_, Http>, - client_id: ClientId, - options: Box, -) -> super::Result { - if http.scope.is_allowed(&options.url) { - let client = http - .clients - .lock() - .unwrap() - .get(&client_id) - .ok_or_else(|| crate::Error::HttpClientNotInitialized)? - .clone(); - let options = *options; - if let Some(Body::Form(form)) = &options.body { - for value in form.0.values() { - if let FormPart::File { - file: FilePart::Path(path), - .. - } = value - { - if SafePathBuf::new(path.clone()).is_err() - || !app - .try_fs_scope() - .map(|s| s.is_allowed(path)) - .unwrap_or_default() - { - return Err(crate::Error::PathNotAllowed(path.clone())); - } - } - } - } - let response = client.send(options).await?; - Ok(response.read().await?) - } else { - Err(crate::Error::UrlNotAllowed(options.url)) - } -} diff --git a/plugins/http/src/config.rs b/plugins/http/src/config.rs index 4e9d7317e2..e4ac882b35 100644 --- a/plugins/http/src/config.rs +++ b/plugins/http/src/config.rs @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use reqwest::Url; use serde::Deserialize; #[derive(Deserialize)] @@ -15,9 +14,9 @@ pub struct Config { /// The scoped URL is matched against the request URL using a glob pattern. /// /// Examples: -/// - "https://**": allows all HTTPS urls +/// - "https://*" or "https://**" : allows all HTTPS urls /// - "https://*.github.com/tauri-apps/tauri": allows any subdomain of "github.com" with the "tauri-apps/api" path /// - "https://myapi.service.com/users/*": allows access to any URLs that begins with "https://myapi.service.com/users/" #[allow(rustdoc::bare_urls)] #[derive(Debug, Default, PartialEq, Eq, Clone, Deserialize)] -pub struct HttpAllowlistScope(pub Vec); +pub struct HttpAllowlistScope(pub Vec); diff --git a/plugins/http/src/error.rs b/plugins/http/src/error.rs index 8b49b0f7fd..457b3382f8 100644 --- a/plugins/http/src/error.rs +++ b/plugins/http/src/error.rs @@ -2,10 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use std::path::PathBuf; - -use reqwest::Url; use serde::{Serialize, Serializer}; +use url::Url; + +use crate::RequestId; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -15,19 +15,32 @@ pub enum Error { Io(#[from] std::io::Error), #[error(transparent)] Network(#[from] reqwest::Error), + #[error(transparent)] + Http(#[from] http::Error), + #[error(transparent)] + HttpInvalidHeaderName(#[from] http::header::InvalidHeaderName), + #[error(transparent)] + HttpInvalidHeaderValue(#[from] http::header::InvalidHeaderValue), /// URL not allowed by the scope. #[error("url not allowed on the configured scope: {0}")] UrlNotAllowed(Url), - /// Path not allowed by the scope. - #[error("path not allowed on the configured scope: {0}")] - PathNotAllowed(PathBuf), - /// Client with specified ID not found. - #[error("http client dropped or not initialized")] - HttpClientNotInitialized, + #[error(transparent)] + UrlParseError(#[from] url::ParseError), /// HTTP method error. #[error(transparent)] HttpMethod(#[from] http::method::InvalidMethod), - /// Failed to serialize header value as string. + #[error("scheme {0} not supported")] + SchemeNotSupport(String), + #[error("Request canceled")] + RequestCanceled, + #[error(transparent)] + FsError(#[from] tauri_plugin_fs::Error), + #[error("failed to process data url")] + DataUrlError, + #[error("failed to decode data url into bytes")] + DataUrlDecodeError, + #[error("invalid request id: {0}")] + InvalidRequestId(RequestId), #[error(transparent)] Utf8(#[from] std::string::FromUtf8Error), } @@ -40,3 +53,5 @@ impl Serialize for Error { serializer.serialize_str(self.to_string().as_ref()) } } + +pub type Result = std::result::Result; diff --git a/plugins/http/src/lib.rs b/plugins/http/src/lib.rs index 8b9b2d577c..7bf9217b30 100644 --- a/plugins/http/src/lib.rs +++ b/plugins/http/src/lib.rs @@ -6,39 +6,56 @@ //! //! Access the HTTP client written in Rust. -#![doc( - html_logo_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png", - html_favicon_url = "https://github.com/tauri-apps/tauri/raw/dev/app-icon.png" -)] +use std::sync::atomic::AtomicU32; +use std::{collections::HashMap, future::Future, pin::Pin}; -use config::{Config, HttpAllowlistScope}; -pub use reqwest as client; +pub use reqwest; +use reqwest::Response; +use tauri::async_runtime::Mutex; use tauri::{ plugin::{Builder, TauriPlugin}, AppHandle, Manager, Runtime, }; -use std::{collections::HashMap, sync::Mutex}; +use crate::config::{Config, HttpAllowlistScope}; +pub use error::{Error, Result}; mod commands; mod config; mod error; mod scope; -pub use error::Error; -type Result = std::result::Result; -type ClientId = u32; +type RequestId = u32; +type CancelableResponseResult = Result>; +type CancelableResponseFuture = + Pin + Send + Sync>>; +type RequestTable = HashMap; +type ResponseTable = HashMap; -pub struct Http { +struct FetchRequest(Mutex); +impl FetchRequest { + fn new(f: CancelableResponseFuture) -> Self { + Self(Mutex::new(f)) + } +} + +struct Http { #[allow(dead_code)] app: AppHandle, - pub(crate) clients: Mutex>, - pub(crate) scope: scope::Scope, + scope: scope::Scope, + current_id: AtomicU32, + requests: Mutex, + responses: Mutex, } -impl Http {} +impl Http { + fn next_id(&self) -> RequestId { + self.current_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } +} -pub trait HttpExt { +trait HttpExt { fn http(&self) -> &Http; } @@ -52,15 +69,18 @@ pub fn init() -> TauriPlugin> { Builder::>::new("http") .js_init_script(include_str!("api-iife.js").to_string()) .invoke_handler(tauri::generate_handler![ - commands::create_client, - commands::drop_client, - commands::request + commands::fetch, + commands::fetch_cancel, + commands::fetch_send, + commands::fetch_read_body, ]) .setup(|app, api| { let default_scope = HttpAllowlistScope::default(); app.manage(Http { app: app.clone(), - clients: Default::default(), + current_id: 0.into(), + requests: Default::default(), + responses: Default::default(), scope: scope::Scope::new( api.config() .as_ref() diff --git a/plugins/http/src/scope.rs b/plugins/http/src/scope.rs index 1b802ace9e..00ef7e083d 100644 --- a/plugins/http/src/scope.rs +++ b/plugins/http/src/scope.rs @@ -2,10 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT -use crate::config::HttpAllowlistScope; use glob::Pattern; use reqwest::Url; +use crate::config::HttpAllowlistScope; + /// Scope for filesystem access. #[derive(Debug, Clone)] pub struct Scope { @@ -20,7 +21,7 @@ impl Scope { .0 .iter() .map(|url| { - glob::Pattern::new(url.as_str()).unwrap_or_else(|_| { + glob::Pattern::new(url).unwrap_or_else(|_| { panic!("scoped URL is not a valid glob pattern: `{url}`") }) }) @@ -30,9 +31,10 @@ impl Scope { /// Determines if the given URL is allowed on this scope. pub fn is_allowed(&self, url: &Url) -> bool { - self.allowed_urls - .iter() - .any(|allowed| allowed.matches(url.as_str())) + self.allowed_urls.iter().any(|allowed| { + allowed.matches(url.as_str()) + || allowed.matches(url.as_str().strip_suffix('/').unwrap_or_default()) + }) } } @@ -79,7 +81,7 @@ mod tests { let scope = super::Scope::new(&HttpAllowlistScope(vec!["http://*".parse().unwrap()])); assert!(scope.is_allowed(&"http://something.else".parse().unwrap())); - assert!(!scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); + assert!(scope.is_allowed(&"http://something.else/path/to/file".parse().unwrap())); assert!(!scope.is_allowed(&"https://something.else".parse().unwrap())); let scope = super::Scope::new(&HttpAllowlistScope(vec!["http://**".parse().unwrap()]));