diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9238213..1befc01 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -493,6 +493,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -516,9 +526,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -529,7 +539,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -829,6 +839,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.0" @@ -951,6 +970,15 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -958,7 +986,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -972,6 +1000,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1382,6 +1416,25 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.12.1", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1480,6 +1533,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1491,6 +1545,38 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.18" @@ -1510,9 +1596,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1971,6 +2059,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2013,6 +2111,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2387,6 +2502,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2978,22 +3137,31 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "mime_guess", + "native-tls", "percent-encoding", "pin-project-lite", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-util", "tower", "tower-http", @@ -3012,15 +3180,32 @@ dependencies = [ "chrono", "directories", "redb", + "reqwest", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", "thiserror 2.0.17", + "tokio", + "urlencoding", "uuid", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -3043,6 +3228,39 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3064,6 +3282,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3121,6 +3348,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3394,7 +3644,7 @@ dependencies = [ "bytemuck", "cfg_aliases", "core-graphics", - "foreign-types", + "foreign-types 0.5.0", "js-sys", "log", "objc2 0.5.2", @@ -3476,6 +3726,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3529,6 +3785,27 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3550,7 +3827,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7" dependencies = [ "bitflags 2.10.0", "block2 0.6.2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch", @@ -3969,9 +4246,41 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.17" @@ -4249,6 +4558,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -4261,6 +4576,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.7" @@ -4273,6 +4594,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" @@ -4309,6 +4636,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4691,6 +5024,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4745,6 +5089,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5264,6 +5617,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d55a1ab..1f6e874 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,3 +32,8 @@ thiserror = "2" chrono = { version = "0.4", features = ["serde"] } directories = "5" +# HTTP client +reqwest = { version = "0.12", features = ["json", "multipart"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +urlencoding = "2" + diff --git a/src-tauri/src/collections/commands.rs b/src-tauri/src/collections/commands.rs new file mode 100644 index 0000000..41e5910 --- /dev/null +++ b/src-tauri/src/collections/commands.rs @@ -0,0 +1,51 @@ +use tauri::State; + +use crate::db::Database; + +use super::service::CollectionService; +use super::types::{Collection, CreateCollectionInput, UpdateCollectionInput}; + +#[tauri::command] +pub fn get_collections(db: State) -> Result, String> { + let service = CollectionService::new(db.inner().clone()); + service.get_all().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_collection(db: State, id: String) -> Result { + let service = CollectionService::new(db.inner().clone()); + service.get(&id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_collections_by_workspace( + db: State, + workspace_id: String, +) -> Result, String> { + let service = CollectionService::new(db.inner().clone()); + service.get_by_workspace(&workspace_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn create_collection( + db: State, + input: CreateCollectionInput, +) -> Result { + let service = CollectionService::new(db.inner().clone()); + service.create(input).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_collection( + db: State, + input: UpdateCollectionInput, +) -> Result { + let service = CollectionService::new(db.inner().clone()); + service.update(input).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_collection(db: State, id: String) -> Result<(), String> { + let service = CollectionService::new(db.inner().clone()); + service.delete(&id).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/collections/mod.rs b/src-tauri/src/collections/mod.rs new file mode 100644 index 0000000..78cd9c0 --- /dev/null +++ b/src-tauri/src/collections/mod.rs @@ -0,0 +1,8 @@ +mod commands; +mod service; +mod types; + +pub use commands::*; +#[allow(unused_imports)] +pub use types::{Collection, CreateCollectionInput, UpdateCollectionInput}; +pub(crate) use service::CollectionService; diff --git a/src-tauri/src/collections/service.rs b/src-tauri/src/collections/service.rs new file mode 100644 index 0000000..6ab9e79 --- /dev/null +++ b/src-tauri/src/collections/service.rs @@ -0,0 +1,164 @@ +use chrono::Utc; +use redb::ReadableTable; + +use crate::db::{ + Database, DbError, DbResult, COLLECTIONS, COLLECTIONS_BY_WORKSPACE, +}; + +use super::types::{Collection, CreateCollectionInput, UpdateCollectionInput}; + +pub struct CollectionService { + db: Database, +} + +impl CollectionService { + pub fn new(db: Database) -> Self { + Self { db } + } + + pub fn get_all(&self) -> DbResult> { + let read_txn = self.db.begin_read()?; + let table = read_txn.open_table(COLLECTIONS)?; + + let mut collections = Vec::new(); + for entry in table.iter()? { + let (_, value) = entry?; + let collection: Collection = serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?; + collections.push(collection); + } + + collections.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(collections) + } + + pub fn get(&self, id: &str) -> DbResult { + let read_txn = self.db.begin_read()?; + let table = read_txn.open_table(COLLECTIONS)?; + + let value = table + .get(id)? + .ok_or_else(|| DbError::NotFound(format!("Collection not found: {}", id)))?; + + let collection: Collection = serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + Ok(collection) + } + + pub fn get_by_workspace(&self, workspace_id: &str) -> DbResult> { + let read_txn = self.db.begin_read()?; + let idx_table = read_txn.open_table(COLLECTIONS_BY_WORKSPACE)?; + + let collection_ids: Vec = match idx_table.get(workspace_id)? { + Some(value) => serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?, + None => return Ok(Vec::new()), + }; + + drop(idx_table); + drop(read_txn); + + let mut collections = Vec::new(); + for id in collection_ids { + if let Ok(collection) = self.get(&id) { + collections.push(collection); + } + } + + collections.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(collections) + } + + pub fn create(&self, input: CreateCollectionInput) -> DbResult { + let collection = Collection::new(input.name, input.description, input.workspace_id.clone()); + + let json = serde_json::to_string(&collection) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + let write_txn = self.db.begin_write()?; + + { + let mut table = write_txn.open_table(COLLECTIONS)?; + table.insert(collection.id.as_str(), json.as_str())?; + } + + // Update index + { + let mut idx_table = write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?; + let ids_json = match idx_table.get(input.workspace_id.as_str())? { + Some(value) => value.value().to_string(), + None => "[]".to_string(), + }; + + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + ids.push(collection.id.clone()); + + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + idx_table.insert(input.workspace_id.as_str(), new_json.as_str())?; + } + + write_txn.commit()?; + + Ok(collection) + } + + pub fn update(&self, input: UpdateCollectionInput) -> DbResult { + let mut collection = self.get(&input.id)?; + + if let Some(name) = input.name { + collection.name = name; + } + if let Some(description) = input.description { + collection.description = description; + } + collection.updated_at = Utc::now(); + + let json = serde_json::to_string(&collection) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + let write_txn = self.db.begin_write()?; + { + let mut table = write_txn.open_table(COLLECTIONS)?; + table.insert(collection.id.as_str(), json.as_str())?; + } + write_txn.commit()?; + + Ok(collection) + } + + pub fn delete(&self, id: &str) -> DbResult<()> { + let collection = self.get(id)?; + + let write_txn = self.db.begin_write()?; + + // Remove from collections table + { + let mut table = write_txn.open_table(COLLECTIONS)?; + table.remove(id)?; + } + + // Update index + { + let mut idx_table = write_txn.open_table(COLLECTIONS_BY_WORKSPACE)?; + let ids_json = idx_table + .get(collection.workspace_id.as_str())? + .map(|v| v.value().to_string()) + .unwrap_or_else(|| "[]".to_string()); + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + ids.retain(|i| i != id); + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + idx_table.insert(collection.workspace_id.as_str(), new_json.as_str())?; + } + + write_txn.commit()?; + + Ok(()) + } +} diff --git a/src-tauri/src/collections/types.rs b/src-tauri/src/collections/types.rs new file mode 100644 index 0000000..5ca1155 --- /dev/null +++ b/src-tauri/src/collections/types.rs @@ -0,0 +1,43 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Collection { + pub id: String, + pub name: String, + pub description: String, + pub workspace_id: String, + #[serde(default = "Utc::now")] + pub created_at: DateTime, + #[serde(default = "Utc::now")] + pub updated_at: DateTime, +} + +impl Collection { + pub fn new(name: String, description: String, workspace_id: String) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + name, + description, + workspace_id, + created_at: now, + updated_at: now, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateCollectionInput { + pub name: String, + pub description: String, + pub workspace_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateCollectionInput { + pub id: String, + pub name: Option, + pub description: Option, +} diff --git a/src-tauri/src/http/client.rs b/src-tauri/src/http/client.rs new file mode 100644 index 0000000..7f51af3 --- /dev/null +++ b/src-tauri/src/http/client.rs @@ -0,0 +1,174 @@ +use std::time::Instant; + +use reqwest::{header::HeaderMap, Client, Method}; + +use super::types::{HttpRequest, HttpResponse, HttpResponseHeader}; + +pub async fn execute_request(request: HttpRequest) -> Result { + let client = Client::new(); + let start = Instant::now(); + + // Parse method + let method = match request.method.to_uppercase().as_str() { + "GET" => Method::GET, + "POST" => Method::POST, + "PUT" => Method::PUT, + "PATCH" => Method::PATCH, + "DELETE" => Method::DELETE, + "HEAD" => Method::HEAD, + "OPTIONS" => Method::OPTIONS, + _ => return Err(format!("Unsupported HTTP method: {}", request.method)), + }; + + // Build URL with query params + let mut url = request.url.clone(); + let enabled_params: Vec<_> = request + .params + .iter() + .filter(|p| p.enabled && !p.key.is_empty()) + .collect(); + + if !enabled_params.is_empty() { + let query_string: String = enabled_params + .iter() + .map(|p| format!("{}={}", urlencoding::encode(&p.key), urlencoding::encode(&p.value))) + .collect::>() + .join("&"); + + if url.contains('?') { + url = format!("{}&{}", url, query_string); + } else { + url = format!("{}?{}", url, query_string); + } + } + + // Build headers + let mut headers = HeaderMap::new(); + for header in &request.headers { + if header.enabled && !header.key.is_empty() { + if let (Ok(name), Ok(value)) = ( + header.key.parse::(), + header.value.parse::(), + ) { + headers.insert(name, value); + } + } + } + + // Build request + let mut req_builder = client.request(method, &url).headers(headers); + + // Add body based on body type + match request.body_type.as_str() { + "json" => { + req_builder = req_builder + .header("Content-Type", "application/json") + .body(request.body.clone()); + } + "xml" => { + req_builder = req_builder + .header("Content-Type", "application/xml") + .body(request.body.clone()); + } + "text" => { + req_builder = req_builder + .header("Content-Type", "text/plain") + .body(request.body.clone()); + } + "html" => { + req_builder = req_builder + .header("Content-Type", "text/html") + .body(request.body.clone()); + } + "x-www-form-urlencoded" => { + let enabled_form: Vec<_> = request + .form_data + .iter() + .filter(|f| f.enabled && !f.key.is_empty()) + .collect(); + + let form_string: String = enabled_form + .iter() + .map(|f| format!("{}={}", urlencoding::encode(&f.key), urlencoding::encode(&f.value))) + .collect::>() + .join("&"); + + req_builder = req_builder + .header("Content-Type", "application/x-www-form-urlencoded") + .body(form_string); + } + "form-data" => { + let mut form = reqwest::multipart::Form::new(); + for item in &request.form_data { + if item.enabled && !item.key.is_empty() { + if item.item_type == "file" { + // For file uploads, read the file + match std::fs::read(&item.value) { + Ok(contents) => { + let file_name = std::path::Path::new(&item.value) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + let part = reqwest::multipart::Part::bytes(contents) + .file_name(file_name); + form = form.part(item.key.clone(), part); + } + Err(e) => { + return Err(format!("Failed to read file {}: {}", item.value, e)); + } + } + } else { + form = form.text(item.key.clone(), item.value.clone()); + } + } + } + req_builder = req_builder.multipart(form); + } + _ => { + // "none" or unknown - no body + } + } + + // Execute request + let response = req_builder + .send() + .await + .map_err(|e| format!("Request failed: {}", e))?; + + let elapsed = start.elapsed(); + let status = response.status().as_u16(); + let status_text = response + .status() + .canonical_reason() + .unwrap_or("Unknown") + .to_string(); + + // Collect headers + let response_headers: Vec = response + .headers() + .iter() + .map(|(k, v)| HttpResponseHeader { + key: k.to_string(), + value: v.to_str().unwrap_or("").to_string(), + }) + .collect(); + + // Get body + let body_bytes = response + .bytes() + .await + .map_err(|e| format!("Failed to read response body: {}", e))?; + + let size_bytes = body_bytes.len(); + let body = String::from_utf8_lossy(&body_bytes).to_string(); + + Ok(HttpResponse { + status, + status_text, + headers: response_headers, + body, + time_ms: elapsed.as_millis() as u64, + size_bytes, + }) +} diff --git a/src-tauri/src/http/mod.rs b/src-tauri/src/http/mod.rs new file mode 100644 index 0000000..4c922ab --- /dev/null +++ b/src-tauri/src/http/mod.rs @@ -0,0 +1,5 @@ +mod client; +mod types; + +pub use client::execute_request; +pub use types::{HttpRequest, HttpResponse, HttpResponseHeader}; diff --git a/src-tauri/src/http/types.rs b/src-tauri/src/http/types.rs new file mode 100644 index 0000000..1df2074 --- /dev/null +++ b/src-tauri/src/http/types.rs @@ -0,0 +1,55 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpRequest { + pub method: String, + pub url: String, + #[serde(default)] + pub headers: Vec, + #[serde(default)] + pub params: Vec, + pub body_type: String, + #[serde(default)] + pub body: String, + #[serde(default)] + pub form_data: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpRequestHeader { + pub key: String, + pub value: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpRequestParam { + pub key: String, + pub value: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpFormDataItem { + pub key: String, + pub value: String, + #[serde(rename = "type")] + pub item_type: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpResponse { + pub status: u16, + pub status_text: String, + pub headers: Vec, + pub body: String, + pub time_ms: u64, + pub size_bytes: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpResponseHeader { + pub key: String, + pub value: String, +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 036ad09..996381f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,28 @@ // Resona - API Client Application +mod collections; mod db; +mod http; +mod requests; +mod variables; mod workspaces; use db::Database; +use http::{HttpRequest, HttpResponse}; -// Re-export workspace commands for generate_handler macro +use collections::{ + create_collection, delete_collection, get_collection, get_collections, + get_collections_by_workspace, update_collection, +}; +use requests::{ + create_request, delete_request, get_all_requests_by_workspace, get_request, + get_requests_by_collection, get_standalone_requests_by_workspace, update_request, +}; +use variables::{ + create_variable, delete_variable, get_collection_variables, get_global_variables, + get_request_variables, get_resolved_variables, get_variable, get_workspace_variables, + update_variable, +}; use workspaces::{ add_workspace_to_sync_group, create_sync_group, create_workspace, delete_sync_group, delete_workspace, get_sync_group, get_sync_group_for_workspace, get_sync_groups, @@ -13,9 +30,13 @@ use workspaces::{ update_sync_group, update_workspace, }; +#[tauri::command] +async fn send_http_request(request: HttpRequest) -> Result { + http::execute_request(request).await +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Initializing the database let db = Database::open().expect("Failed to initialize database"); tauri::Builder::default() @@ -38,6 +59,33 @@ pub fn run() { get_workspaces_by_sync_group, add_workspace_to_sync_group, remove_workspace_from_sync_group, + // Collection commands + get_collections, + get_collection, + get_collections_by_workspace, + create_collection, + update_collection, + delete_collection, + // Request commands + get_request, + get_requests_by_collection, + get_standalone_requests_by_workspace, + get_all_requests_by_workspace, + create_request, + update_request, + delete_request, + // Variable commands + get_variable, + get_global_variables, + get_workspace_variables, + get_collection_variables, + get_request_variables, + get_resolved_variables, + create_variable, + update_variable, + delete_variable, + // HTTP client + send_http_request, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/requests/commands.rs b/src-tauri/src/requests/commands.rs new file mode 100644 index 0000000..622d117 --- /dev/null +++ b/src-tauri/src/requests/commands.rs @@ -0,0 +1,63 @@ +use tauri::State; + +use crate::db::Database; + +use super::service::RequestService; +use super::types::{CreateRequestInput, Request, UpdateRequestInput}; + +#[tauri::command] +pub fn get_request(db: State, id: String) -> Result { + let service = RequestService::new(db.inner().clone()); + service.get(&id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_requests_by_collection( + db: State, + collection_id: String, +) -> Result, String> { + let service = RequestService::new(db.inner().clone()); + service.get_by_collection(&collection_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_standalone_requests_by_workspace( + db: State, + workspace_id: String, +) -> Result, String> { + let service = RequestService::new(db.inner().clone()); + service.get_standalone_by_workspace(&workspace_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_all_requests_by_workspace( + db: State, + workspace_id: String, +) -> Result, String> { + let service = RequestService::new(db.inner().clone()); + service.get_all_by_workspace(&workspace_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn create_request( + db: State, + input: CreateRequestInput, +) -> Result { + let service = RequestService::new(db.inner().clone()); + service.create(input).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_request( + db: State, + input: UpdateRequestInput, +) -> Result { + let service = RequestService::new(db.inner().clone()); + service.update(input).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_request(db: State, id: String) -> Result<(), String> { + let service = RequestService::new(db.inner().clone()); + service.delete(&id).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/requests/mod.rs b/src-tauri/src/requests/mod.rs new file mode 100644 index 0000000..65e791d --- /dev/null +++ b/src-tauri/src/requests/mod.rs @@ -0,0 +1,11 @@ +mod commands; +mod service; +mod types; + +pub use commands::*; +#[allow(unused_imports)] +pub use types::{ + BodyType, CreateRequestInput, FormDataItem, HttpMethod, Request, RequestHeader, + RequestParam, UpdateRequestInput, +}; +pub(crate) use service::RequestService; diff --git a/src-tauri/src/requests/service.rs b/src-tauri/src/requests/service.rs new file mode 100644 index 0000000..815e922 --- /dev/null +++ b/src-tauri/src/requests/service.rs @@ -0,0 +1,298 @@ +use chrono::Utc; +use redb::ReadableTable; + +use crate::db::{ + Database, DbError, DbResult, REQUESTS, REQUESTS_BY_COLLECTION, REQUESTS_BY_WORKSPACE, +}; + +use super::types::{CreateRequestInput, Request, UpdateRequestInput}; + +pub struct RequestService { + db: Database, +} + +impl RequestService { + pub fn new(db: Database) -> Self { + Self { db } + } + + pub fn get(&self, id: &str) -> DbResult { + let read_txn = self.db.begin_read()?; + let table = read_txn.open_table(REQUESTS)?; + + let value = table + .get(id)? + .ok_or_else(|| DbError::NotFound(format!("Request not found: {}", id)))?; + + let request: Request = serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + Ok(request) + } + + pub fn get_by_collection(&self, collection_id: &str) -> DbResult> { + let read_txn = self.db.begin_read()?; + let idx_table = read_txn.open_table(REQUESTS_BY_COLLECTION)?; + + let request_ids: Vec = match idx_table.get(collection_id)? { + Some(value) => serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?, + None => return Ok(Vec::new()), + }; + + drop(idx_table); + drop(read_txn); + + let mut requests = Vec::new(); + for id in request_ids { + if let Ok(request) = self.get(&id) { + requests.push(request); + } + } + + requests.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(requests) + } + + pub fn get_standalone_by_workspace(&self, workspace_id: &str) -> DbResult> { + let read_txn = self.db.begin_read()?; + let idx_table = read_txn.open_table(REQUESTS_BY_WORKSPACE)?; + + let request_ids: Vec = match idx_table.get(workspace_id)? { + Some(value) => serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?, + None => return Ok(Vec::new()), + }; + + drop(idx_table); + drop(read_txn); + + let mut requests = Vec::new(); + for id in request_ids { + if let Ok(request) = self.get(&id) { + if request.collection_id.is_none() { + requests.push(request); + } + } + } + + requests.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(requests) + } + + pub fn get_all_by_workspace(&self, workspace_id: &str) -> DbResult> { + let read_txn = self.db.begin_read()?; + let idx_table = read_txn.open_table(REQUESTS_BY_WORKSPACE)?; + + let request_ids: Vec = match idx_table.get(workspace_id)? { + Some(value) => serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?, + None => return Ok(Vec::new()), + }; + + drop(idx_table); + drop(read_txn); + + let mut requests = Vec::new(); + for id in request_ids { + if let Ok(request) = self.get(&id) { + requests.push(request); + } + } + + requests.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(requests) + } + + pub fn create(&self, input: CreateRequestInput) -> DbResult { + let mut request = Request::new(input.name, input.method, input.workspace_id.clone()); + request.url = input.url; + request.headers = input.headers; + request.params = input.params; + request.body_type = input.body_type; + request.body = input.body; + request.form_data = input.form_data; + request.collection_id = input.collection_id.clone(); + + let json = serde_json::to_string(&request) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + let write_txn = self.db.begin_write()?; + + { + let mut table = write_txn.open_table(REQUESTS)?; + table.insert(request.id.as_str(), json.as_str())?; + } + + // Update workspace index + { + let mut idx_table = write_txn.open_table(REQUESTS_BY_WORKSPACE)?; + let ids_json = match idx_table.get(input.workspace_id.as_str())? { + Some(value) => value.value().to_string(), + None => "[]".to_string(), + }; + + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + ids.push(request.id.clone()); + + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + idx_table.insert(input.workspace_id.as_str(), new_json.as_str())?; + } + + // Update collection index if applicable + if let Some(ref collection_id) = input.collection_id { + let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?; + let ids_json = match idx_table.get(collection_id.as_str())? { + Some(value) => value.value().to_string(), + None => "[]".to_string(), + }; + + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + ids.push(request.id.clone()); + + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + idx_table.insert(collection_id.as_str(), new_json.as_str())?; + } + + write_txn.commit()?; + + Ok(request) + } + + pub fn update(&self, input: UpdateRequestInput) -> DbResult { + let mut request = self.get(&input.id)?; + let old_collection_id = request.collection_id.clone(); + + if let Some(name) = input.name { + request.name = name; + } + if let Some(method) = input.method { + request.method = method; + } + if let Some(url) = input.url { + request.url = url; + } + if let Some(headers) = input.headers { + request.headers = headers; + } + if let Some(params) = input.params { + request.params = params; + } + if let Some(body_type) = input.body_type { + request.body_type = body_type; + } + if let Some(body) = input.body { + request.body = body; + } + if let Some(form_data) = input.form_data { + request.form_data = form_data; + } + if let Some(collection_id) = input.collection_id { + request.collection_id = collection_id; + } + request.updated_at = Utc::now(); + + let json = serde_json::to_string(&request) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + let write_txn = self.db.begin_write()?; + + { + let mut table = write_txn.open_table(REQUESTS)?; + table.insert(request.id.as_str(), json.as_str())?; + } + + // Update collection index if collection changed + if old_collection_id != request.collection_id { + // Remove from old collection index + if let Some(ref old_id) = old_collection_id { + let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?; + let ids_json = idx_table + .get(old_id.as_str())? + .map(|v| v.value().to_string()) + .unwrap_or_else(|| "[]".to_string()); + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + ids.retain(|i| i != &request.id); + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + idx_table.insert(old_id.as_str(), new_json.as_str())?; + } + + // Add to new collection index + if let Some(ref new_id) = request.collection_id { + let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?; + let ids_json = idx_table + .get(new_id.as_str())? + .map(|v| v.value().to_string()) + .unwrap_or_else(|| "[]".to_string()); + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + if !ids.contains(&request.id) { + ids.push(request.id.clone()); + } + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + idx_table.insert(new_id.as_str(), new_json.as_str())?; + } + } + + write_txn.commit()?; + + Ok(request) + } + + pub fn delete(&self, id: &str) -> DbResult<()> { + let request = self.get(id)?; + + let write_txn = self.db.begin_write()?; + + // Remove from requests table + { + let mut table = write_txn.open_table(REQUESTS)?; + table.remove(id)?; + } + + // Update workspace index + { + let mut idx_table = write_txn.open_table(REQUESTS_BY_WORKSPACE)?; + let ids_json = idx_table + .get(request.workspace_id.as_str())? + .map(|v| v.value().to_string()) + .unwrap_or_else(|| "[]".to_string()); + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + ids.retain(|i| i != id); + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + idx_table.insert(request.workspace_id.as_str(), new_json.as_str())?; + } + + // Update collection index if applicable + if let Some(ref collection_id) = request.collection_id { + let mut idx_table = write_txn.open_table(REQUESTS_BY_COLLECTION)?; + let ids_json = idx_table + .get(collection_id.as_str())? + .map(|v| v.value().to_string()) + .unwrap_or_else(|| "[]".to_string()); + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + ids.retain(|i| i != id); + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + idx_table.insert(collection_id.as_str(), new_json.as_str())?; + } + + write_txn.commit()?; + + Ok(()) + } +} diff --git a/src-tauri/src/requests/types.rs b/src-tauri/src/requests/types.rs new file mode 100644 index 0000000..55b09a8 --- /dev/null +++ b/src-tauri/src/requests/types.rs @@ -0,0 +1,142 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + Get, + Post, + Put, + Patch, + Delete, + Head, + Options, +} + +impl Default for HttpMethod { + fn default() -> Self { + Self::Get + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum BodyType { + None, + Json, + Xml, + Text, + Html, + FormData, + #[serde(rename = "x-www-form-urlencoded")] + XWwwFormUrlencoded, +} + +impl Default for BodyType { + fn default() -> Self { + Self::None + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestHeader { + pub key: String, + pub value: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RequestParam { + pub key: String, + pub value: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FormDataItem { + pub key: String, + pub value: String, + #[serde(rename = "type")] + pub item_type: String, // "text" or "file" + pub enabled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub id: String, + pub name: String, + pub method: HttpMethod, + pub url: String, + #[serde(default)] + pub headers: Vec, + #[serde(default)] + pub params: Vec, + #[serde(default)] + pub body_type: BodyType, + #[serde(default)] + pub body: String, + #[serde(default)] + pub form_data: Vec, + pub collection_id: Option, + pub workspace_id: String, + #[serde(default = "Utc::now")] + pub created_at: DateTime, + #[serde(default = "Utc::now")] + pub updated_at: DateTime, +} + +impl Request { + pub fn new(name: String, method: HttpMethod, workspace_id: String) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + name, + method, + url: String::new(), + headers: Vec::new(), + params: Vec::new(), + body_type: BodyType::None, + body: String::new(), + form_data: Vec::new(), + collection_id: None, + workspace_id, + created_at: now, + updated_at: now, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateRequestInput { + pub name: String, + pub method: HttpMethod, + #[serde(default)] + pub url: String, + #[serde(default)] + pub headers: Vec, + #[serde(default)] + pub params: Vec, + #[serde(default)] + pub body_type: BodyType, + #[serde(default)] + pub body: String, + #[serde(default)] + pub form_data: Vec, + pub collection_id: Option, + pub workspace_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateRequestInput { + pub id: String, + pub name: Option, + pub method: Option, + pub url: Option, + pub headers: Option>, + pub params: Option>, + pub body_type: Option, + pub body: Option, + pub form_data: Option>, + pub collection_id: Option>, +} diff --git a/src-tauri/src/variables/commands.rs b/src-tauri/src/variables/commands.rs new file mode 100644 index 0000000..d4d6043 --- /dev/null +++ b/src-tauri/src/variables/commands.rs @@ -0,0 +1,86 @@ +use tauri::State; + +use crate::db::Database; + +use super::service::VariableService; +use super::types::{CreateVariableInput, ResolvedVariable, UpdateVariableInput, Variable}; + +#[tauri::command] +pub fn get_variable(db: State, id: String) -> Result { + let service = VariableService::new(db.inner().clone()); + service.get(&id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_global_variables(db: State) -> Result, String> { + let service = VariableService::new(db.inner().clone()); + service.get_global().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_workspace_variables( + db: State, + workspace_id: String, +) -> Result, String> { + let service = VariableService::new(db.inner().clone()); + service.get_by_workspace(&workspace_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_collection_variables( + db: State, + collection_id: String, +) -> Result, String> { + let service = VariableService::new(db.inner().clone()); + service.get_by_collection(&collection_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_request_variables( + db: State, + request_id: String, +) -> Result, String> { + let service = VariableService::new(db.inner().clone()); + service.get_by_request(&request_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_resolved_variables( + db: State, + workspace_id: Option, + collection_id: Option, + request_id: Option, +) -> Result, String> { + let service = VariableService::new(db.inner().clone()); + service + .get_resolved( + workspace_id.as_deref(), + collection_id.as_deref(), + request_id.as_deref(), + ) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn create_variable( + db: State, + input: CreateVariableInput, +) -> Result { + let service = VariableService::new(db.inner().clone()); + service.create(input).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn update_variable( + db: State, + input: UpdateVariableInput, +) -> Result { + let service = VariableService::new(db.inner().clone()); + service.update(input).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn delete_variable(db: State, id: String) -> Result<(), String> { + let service = VariableService::new(db.inner().clone()); + service.delete(&id).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/variables/mod.rs b/src-tauri/src/variables/mod.rs new file mode 100644 index 0000000..2f5cce9 --- /dev/null +++ b/src-tauri/src/variables/mod.rs @@ -0,0 +1,8 @@ +mod commands; +mod service; +mod types; + +pub use commands::*; +#[allow(unused_imports)] +pub use types::{CreateVariableInput, UpdateVariableInput, Variable, VariableScope}; +pub(crate) use service::VariableService; diff --git a/src-tauri/src/variables/service.rs b/src-tauri/src/variables/service.rs new file mode 100644 index 0000000..08f75b3 --- /dev/null +++ b/src-tauri/src/variables/service.rs @@ -0,0 +1,236 @@ +use chrono::Utc; +use redb::ReadableTable; + +use crate::db::{Database, DbError, DbResult, VARIABLES, VARIABLES_BY_SCOPE}; + +use super::types::{CreateVariableInput, ResolvedVariable, UpdateVariableInput, Variable, VariableScope}; + +pub struct VariableService { + db: Database, +} + +impl VariableService { + pub fn new(db: Database) -> Self { + Self { db } + } + + pub fn get(&self, id: &str) -> DbResult { + let read_txn = self.db.begin_read()?; + let table = read_txn.open_table(VARIABLES)?; + + let value = table + .get(id)? + .ok_or_else(|| DbError::NotFound(format!("Variable not found: {}", id)))?; + + let variable: Variable = serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + Ok(variable) + } + + fn get_by_scope_key(&self, scope_key: &str) -> DbResult> { + let read_txn = self.db.begin_read()?; + let idx_table = read_txn.open_table(VARIABLES_BY_SCOPE)?; + + let variable_ids: Vec = match idx_table.get(scope_key)? { + Some(value) => serde_json::from_str(value.value()) + .map_err(|e| DbError::Serialization(e.to_string()))?, + None => return Ok(Vec::new()), + }; + + drop(idx_table); + drop(read_txn); + + let mut variables = Vec::new(); + for id in variable_ids { + if let Ok(variable) = self.get(&id) { + variables.push(variable); + } + } + + variables.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(variables) + } + + pub fn get_global(&self) -> DbResult> { + self.get_by_scope_key("global") + } + + pub fn get_by_workspace(&self, workspace_id: &str) -> DbResult> { + self.get_by_scope_key(&format!("workspace:{}", workspace_id)) + } + + pub fn get_by_collection(&self, collection_id: &str) -> DbResult> { + self.get_by_scope_key(&format!("collection:{}", collection_id)) + } + + pub fn get_by_request(&self, request_id: &str) -> DbResult> { + self.get_by_scope_key(&format!("request:{}", request_id)) + } + + pub fn get_resolved( + &self, + workspace_id: Option<&str>, + collection_id: Option<&str>, + request_id: Option<&str>, + ) -> DbResult> { + let mut resolved_map = std::collections::HashMap::new(); + + // Global variables (lowest priority) + for var in self.get_global()? { + resolved_map.insert(var.name.clone(), ResolvedVariable { + name: var.name, + value: var.value, + scope: var.scope, + is_secret: var.is_secret, + }); + } + + // Workspace variables + if let Some(ws_id) = workspace_id { + for var in self.get_by_workspace(ws_id)? { + resolved_map.insert(var.name.clone(), ResolvedVariable { + name: var.name, + value: var.value, + scope: var.scope, + is_secret: var.is_secret, + }); + } + } + + // Collection variables + if let Some(coll_id) = collection_id { + for var in self.get_by_collection(coll_id)? { + resolved_map.insert(var.name.clone(), ResolvedVariable { + name: var.name, + value: var.value, + scope: var.scope, + is_secret: var.is_secret, + }); + } + } + + // Request variables (highest priority) + if let Some(req_id) = request_id { + for var in self.get_by_request(req_id)? { + resolved_map.insert(var.name.clone(), ResolvedVariable { + name: var.name, + value: var.value, + scope: var.scope, + is_secret: var.is_secret, + }); + } + } + + let mut resolved: Vec<_> = resolved_map.into_values().collect(); + resolved.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(resolved) + } + + pub fn create(&self, input: CreateVariableInput) -> DbResult { + let mut variable = Variable::new( + input.name, + input.value, + input.scope, + input.scope_id, + ); + variable.is_secret = input.is_secret; + variable.description = input.description; + + let scope_key = variable.scope_key(); + let json = serde_json::to_string(&variable) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + let write_txn = self.db.begin_write()?; + + { + let mut table = write_txn.open_table(VARIABLES)?; + table.insert(variable.id.as_str(), json.as_str())?; + } + + // Update scope index + { + let mut idx_table = write_txn.open_table(VARIABLES_BY_SCOPE)?; + let ids_json = match idx_table.get(scope_key.as_str())? { + Some(value) => value.value().to_string(), + None => "[]".to_string(), + }; + + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + ids.push(variable.id.clone()); + + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + idx_table.insert(scope_key.as_str(), new_json.as_str())?; + } + + write_txn.commit()?; + + Ok(variable) + } + + pub fn update(&self, input: UpdateVariableInput) -> DbResult { + let mut variable = self.get(&input.id)?; + + if let Some(name) = input.name { + variable.name = name; + } + if let Some(value) = input.value { + variable.value = value; + } + if let Some(is_secret) = input.is_secret { + variable.is_secret = is_secret; + } + if let Some(description) = input.description { + variable.description = Some(description); + } + variable.updated_at = Utc::now(); + + let json = serde_json::to_string(&variable) + .map_err(|e| DbError::Serialization(e.to_string()))?; + + let write_txn = self.db.begin_write()?; + { + let mut table = write_txn.open_table(VARIABLES)?; + table.insert(variable.id.as_str(), json.as_str())?; + } + write_txn.commit()?; + + Ok(variable) + } + + pub fn delete(&self, id: &str) -> DbResult<()> { + let variable = self.get(id)?; + let scope_key = variable.scope_key(); + + let write_txn = self.db.begin_write()?; + + // Remove from variables table + { + let mut table = write_txn.open_table(VARIABLES)?; + table.remove(id)?; + } + + // Update scope index + { + let mut idx_table = write_txn.open_table(VARIABLES_BY_SCOPE)?; + let ids_json = idx_table + .get(scope_key.as_str())? + .map(|v| v.value().to_string()) + .unwrap_or_else(|| "[]".to_string()); + let mut ids: Vec = serde_json::from_str(&ids_json) + .map_err(|e| DbError::Serialization(e.to_string()))?; + ids.retain(|i| i != id); + let new_json = serde_json::to_string(&ids) + .map_err(|e| DbError::Serialization(e.to_string()))?; + idx_table.insert(scope_key.as_str(), new_json.as_str())?; + } + + write_txn.commit()?; + + Ok(()) + } +} diff --git a/src-tauri/src/variables/types.rs b/src-tauri/src/variables/types.rs new file mode 100644 index 0000000..b6a9eec --- /dev/null +++ b/src-tauri/src/variables/types.rs @@ -0,0 +1,87 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum VariableScope { + Global, + Workspace, + Collection, + Request, +} + +impl Default for VariableScope { + fn default() -> Self { + Self::Global + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Variable { + pub id: String, + pub name: String, + pub value: String, + pub scope: VariableScope, + pub scope_id: Option, + pub is_secret: bool, + pub description: Option, + #[serde(default = "Utc::now")] + pub created_at: DateTime, + #[serde(default = "Utc::now")] + pub updated_at: DateTime, +} + +impl Variable { + pub fn new(name: String, value: String, scope: VariableScope, scope_id: Option) -> Self { + let now = Utc::now(); + Self { + id: Uuid::new_v4().to_string(), + name, + value, + scope, + scope_id, + is_secret: false, + description: None, + created_at: now, + updated_at: now, + } + } + + pub fn scope_key(&self) -> String { + match self.scope { + VariableScope::Global => "global".to_string(), + VariableScope::Workspace => format!("workspace:{}", self.scope_id.as_deref().unwrap_or("")), + VariableScope::Collection => format!("collection:{}", self.scope_id.as_deref().unwrap_or("")), + VariableScope::Request => format!("request:{}", self.scope_id.as_deref().unwrap_or("")), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CreateVariableInput { + pub name: String, + pub value: String, + pub scope: VariableScope, + pub scope_id: Option, + #[serde(default)] + pub is_secret: bool, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdateVariableInput { + pub id: String, + pub name: Option, + pub value: Option, + pub is_secret: Option, + pub description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResolvedVariable { + pub name: String, + pub value: String, + pub scope: VariableScope, + pub is_secret: bool, +} diff --git a/src/lib/services/collections.ts b/src/lib/services/collections.ts index 92139da..a2bc69a 100644 --- a/src/lib/services/collections.ts +++ b/src/lib/services/collections.ts @@ -1,188 +1,279 @@ +import { invoke } from "@tauri-apps/api/core"; import type { Collection } from "$lib/types/collection"; -import type { Request, HttpMethod } from "$lib/types/request"; +import type { Request } from "$lib/types/request"; -const collections: Map = new Map([ - [ - "col-1", - { - id: "col-1", - name: "User API", - description: "User management endpoints", - workspaceId: "1", - requests: [ - { - id: "req-1", - name: "Get Users", - method: "GET", - url: "https://api.example.com/users", - headers: [], - params: [], - bodyType: "none", - body: "", - formData: [], - collectionId: "col-1", - workspaceId: "1", - }, - { - id: "req-2", - name: "Create User", - method: "POST", - url: "https://api.example.com/users", - headers: [ - { key: "Content-Type", value: "application/json", enabled: true }, - ], - params: [], - bodyType: "json", - body: '{"name": "John", "email": "john@example.com"}', - formData: [], - collectionId: "col-1", - workspaceId: "1", - }, - ], - }, - ], - [ - "col-2", - { - id: "col-2", - name: "Products API", - description: "Product catalog endpoints", - workspaceId: "1", - requests: [ - { - id: "req-3", - name: "List Products", - method: "GET", - url: "https://api.example.com/products", - headers: [], - params: [{ key: "limit", value: "10", enabled: true }], - bodyType: "none", - body: "", - formData: [], - collectionId: "col-2", - workspaceId: "1", - }, - ], - }, - ], -]); +// Collection types for Rust backend +type RustCollection = { + id: string; + name: string; + description: string; + workspace_id: string; + created_at: string; + updated_at: string; +}; -const standaloneRequests: Map = new Map([ - [ - "req-standalone-1", - { - id: "req-standalone-1", - name: "Health Check", - method: "GET", - url: "https://api.example.com/health", - headers: [], - params: [], - bodyType: "none", - body: "", - formData: [], - collectionId: null, - workspaceId: "1", - }, - ], -]); +type CreateCollectionInput = { + name: string; + description: string; + workspace_id: string; +}; + +type UpdateCollectionInput = { + id: string; + name?: string; + description?: string; +}; + +// Request types for Rust backend +type RustRequest = { + id: string; + name: string; + method: string; + url: string; + headers: { key: string; value: string; enabled: boolean }[]; + params: { key: string; value: string; enabled: boolean }[]; + body_type: string; + body: string; + form_data: { + key: string; + value: string; + item_type: string; + enabled: boolean; + }[]; + collection_id: string | null; + workspace_id: string; + created_at: string; + updated_at: string; +}; + +type CreateRequestInput = { + name: string; + method: string; + url: string; + headers: { key: string; value: string; enabled: boolean }[]; + params: { key: string; value: string; enabled: boolean }[]; + body_type: string; + body: string; + form_data: { + key: string; + value: string; + item_type: string; + enabled: boolean; + }[]; + collection_id: string | null; + workspace_id: string; +}; + +type UpdateRequestInput = { + id: string; + name?: string; + method?: string; + url?: string; + headers?: { key: string; value: string; enabled: boolean }[]; + params?: { key: string; value: string; enabled: boolean }[]; + body_type?: string; + body?: string; + form_data?: { + key: string; + value: string; + item_type: string; + enabled: boolean; + }[]; + collection_id?: string | null; +}; + +// Convert Rust collection to frontend Collection +function toCollection( + rust: RustCollection, + requests: Request[] = [] +): Collection { + return { + id: rust.id, + name: rust.name, + description: rust.description, + workspaceId: rust.workspace_id, + requests, + }; +} + +// Convert Rust request to frontend Request +function toRequest(rust: RustRequest): Request { + return { + id: rust.id, + name: rust.name, + method: rust.method as Request["method"], + url: rust.url, + headers: rust.headers, + params: rust.params, + bodyType: rust.body_type as Request["bodyType"], + body: rust.body, + formData: rust.form_data.map((f) => ({ + key: f.key, + value: f.value, + type: f.item_type as "text" | "file", + enabled: f.enabled, + })), + collectionId: rust.collection_id, + workspaceId: rust.workspace_id, + }; +} export async function get_collections_by_workspace( workspaceId: string ): Promise { - return [...collections.values()].filter((c) => c.workspaceId === workspaceId); + const rustCollections = await invoke( + "get_collections_by_workspace", + { workspaceId } + ); + + const collections: Collection[] = []; + for (const rc of rustCollections) { + const rustRequests = await invoke( + "get_requests_by_collection", + { + collectionId: rc.id, + } + ); + collections.push(toCollection(rc, rustRequests.map(toRequest))); + } + + return collections; } export async function get_collection( id: string ): Promise { - return collections.get(id); + try { + const rc = await invoke("get_collection", { id }); + const rustRequests = await invoke( + "get_requests_by_collection", + { + collectionId: id, + } + ); + return toCollection(rc, rustRequests.map(toRequest)); + } catch { + return undefined; + } } -export async function create_collection( - collection: Omit -): Promise { - const newCollection: Collection = { - ...collection, - id: crypto.randomUUID(), - requests: [], +export async function create_collection(collection: { + name: string; + description: string; + workspaceId: string; +}): Promise { + const input: CreateCollectionInput = { + name: collection.name, + description: collection.description, + workspace_id: collection.workspaceId, }; - collections.set(newCollection.id, newCollection); - return newCollection; + const rc = await invoke("create_collection", { input }); + return toCollection(rc, []); } export async function update_collection( id: string, updates: Partial> ): Promise { - const collection = collections.get(id); - if (!collection) return false; - - if (updates.name !== undefined) collection.name = updates.name; - if (updates.description !== undefined) - collection.description = updates.description; - return true; + try { + const input: UpdateCollectionInput = { + id, + name: updates.name, + description: updates.description, + }; + await invoke("update_collection", { input }); + return true; + } catch { + return false; + } } export async function delete_collection(id: string): Promise { - return collections.delete(id); + try { + await invoke("delete_collection", { id }); + return true; + } catch { + return false; + } } export async function get_standalone_requests_by_workspace( workspaceId: string ): Promise { - return [...standaloneRequests.values()].filter( - (r) => r.workspaceId === workspaceId + const rustRequests = await invoke( + "get_standalone_requests_by_workspace", + { workspaceId } ); + return rustRequests.map(toRequest); } export async function get_request(id: string): Promise { - for (const collection of collections.values()) { - const request = collection.requests.find((r) => r.id === id); - if (request) return request; + try { + const rr = await invoke("get_request", { id }); + return toRequest(rr); + } catch { + return undefined; } - return standaloneRequests.get(id); } export async function create_request( request: Omit ): Promise { - const newRequest: Request = { - ...request, - id: crypto.randomUUID(), + const input: CreateRequestInput = { + name: request.name, + method: request.method, + url: request.url, + headers: request.headers, + params: request.params, + body_type: request.bodyType, + body: request.body, + form_data: request.formData.map((f) => ({ + key: f.key, + value: f.value, + item_type: f.type, + enabled: f.enabled, + })), + collection_id: request.collectionId, + workspace_id: request.workspaceId, }; - - if (request.collectionId) { - const collection = collections.get(request.collectionId); - if (collection) { - collection.requests.push(newRequest); - } - } else { - standaloneRequests.set(newRequest.id, newRequest); - } - - return newRequest; + const rr = await invoke("create_request", { input }); + return toRequest(rr); } export async function update_request( id: string, updates: Partial> ): Promise { - const request = await get_request(id); - if (!request) return false; - - Object.assign(request, updates); - return true; + try { + const input: UpdateRequestInput = { + id, + name: updates.name, + method: updates.method, + url: updates.url, + headers: updates.headers, + params: updates.params, + body_type: updates.bodyType, + body: updates.body, + form_data: updates.formData?.map((f) => ({ + key: f.key, + value: f.value, + item_type: f.type, + enabled: f.enabled, + })), + collection_id: updates.collectionId, + }; + await invoke("update_request", { input }); + return true; + } catch { + return false; + } } export async function delete_request(id: string): Promise { - if (standaloneRequests.delete(id)) return true; - - for (const collection of collections.values()) { - const index = collection.requests.findIndex((r) => r.id === id); - if (index !== -1) { - collection.requests.splice(index, 1); - return true; - } + try { + await invoke("delete_request", { id }); + return true; + } catch { + return false; } - return false; } diff --git a/src/lib/services/http.ts b/src/lib/services/http.ts new file mode 100644 index 0000000..424defa --- /dev/null +++ b/src/lib/services/http.ts @@ -0,0 +1,104 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { Request } from "$lib/types/request"; +import type { ResolvedVariable } from "$lib/types/variable"; +import { interpolate_variables } from "./variables"; + +export type HttpResponse = { + status: number; + statusText: string; + headers: { key: string; value: string }[]; + body: string; + timeMs: number; + sizeBytes: number; +}; + +type RustHttpRequest = { + method: string; + url: string; + headers: { key: string; value: string; enabled: boolean }[]; + params: { key: string; value: string; enabled: boolean }[]; + body_type: string; + body: string; + form_data: { + key: string; + value: string; + item_type: string; + enabled: boolean; + }[]; +}; + +type RustHttpResponse = { + status: number; + status_text: string; + headers: { key: string; value: string }[]; + body: string; + time_ms: number; + size_bytes: number; +}; + +export async function send_request( + request: Request, + variables: ResolvedVariable[] = [] +): Promise { + // Interpolate variables in URL + const url = interpolate_variables(request.url, variables); + + // Interpolate variables in headers + const headers = request.headers.map((h) => ({ + key: interpolate_variables(h.key, variables), + value: interpolate_variables(h.value, variables), + enabled: h.enabled, + })); + + // Interpolate variables in params + const params = request.params.map((p) => ({ + key: interpolate_variables(p.key, variables), + value: interpolate_variables(p.value, variables), + enabled: p.enabled, + })); + + // Interpolate variables in body + const body = interpolate_variables(request.body, variables); + + // Interpolate variables in form data + const formData = request.formData.map((f) => ({ + key: interpolate_variables(f.key, variables), + value: interpolate_variables(f.value, variables), + item_type: f.type, + enabled: f.enabled, + })); + + const rustRequest: RustHttpRequest = { + method: request.method, + url, + headers, + params, + body_type: request.bodyType, + body, + form_data: formData, + }; + + const response = await invoke("send_http_request", { + request: rustRequest, + }); + + return { + status: response.status, + statusText: response.status_text, + headers: response.headers, + body: response.body, + timeMs: response.time_ms, + sizeBytes: response.size_bytes, + }; +} + +export function format_size(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function format_time(ms: number): string { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} diff --git a/src/lib/services/variables.ts b/src/lib/services/variables.ts index b87fac4..d9df9e0 100644 --- a/src/lib/services/variables.ts +++ b/src/lib/services/variables.ts @@ -1,93 +1,116 @@ +import { invoke } from "@tauri-apps/api/core"; import type { Variable, VariableScope, ResolvedVariable, } from "$lib/types/variable"; -const variables: Map = new Map([ - [ - "var-1", - { - id: "var-1", - name: "BASE_URL", - value: "https://api.example.com", - scope: "global", - scopeId: null, - isSecret: false, - description: "Base URL for all API requests", - }, - ], - [ - "var-2", - { - id: "var-2", - name: "API_KEY", - value: "sk-1234567890", - scope: "global", - scopeId: null, - isSecret: true, - description: "API authentication key", - }, - ], - [ - "var-3", - { - id: "var-3", - name: "USER_ID", - value: "user-123", - scope: "workspace", - scopeId: "1", - isSecret: false, - description: "Default user ID for testing", - }, - ], - [ - "var-4", - { - id: "var-4", - name: "AUTH_TOKEN", - value: "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", - scope: "workspace", - scopeId: "1", - isSecret: true, - description: "Authentication token", - }, - ], -]); +// Rust variable types +type RustVariable = { + id: string; + name: string; + value: string; + scope: string; + scope_id: string | null; + is_secret: boolean; + description: string | null; + created_at: string; + updated_at: string; +}; -export async function get_all_variables(): Promise { - return [...variables.values()]; +type RustResolvedVariable = { + name: string; + value: string; + scope: string; + is_secret: boolean; +}; + +type CreateVariableInput = { + name: string; + value: string; + scope: string; + scope_id: string | null; + is_secret: boolean; + description: string | null; +}; + +type UpdateVariableInput = { + id: string; + name?: string; + value?: string; + is_secret?: boolean; + description?: string; +}; + +function toVariable(rust: RustVariable): Variable { + return { + id: rust.id, + name: rust.name, + value: rust.value, + scope: rust.scope as VariableScope, + scopeId: rust.scope_id, + isSecret: rust.is_secret, + description: rust.description ?? undefined, + }; +} + +function toResolvedVariable(rust: RustResolvedVariable): ResolvedVariable { + return { + name: rust.name, + value: rust.value, + scope: rust.scope as VariableScope, + isSecret: rust.is_secret, + }; +} + +export async function get_global_variables(): Promise { + const vars = await invoke("get_global_variables"); + return vars.map(toVariable); +} + +export async function get_workspace_variables( + workspaceId: string +): Promise { + const vars = await invoke("get_workspace_variables", { + workspaceId, + }); + return vars.map(toVariable); +} + +export async function get_collection_variables( + collectionId: string +): Promise { + const vars = await invoke("get_collection_variables", { + collectionId, + }); + return vars.map(toVariable); +} + +export async function get_request_variables( + requestId: string +): Promise { + const vars = await invoke("get_request_variables", { + requestId, + }); + return vars.map(toVariable); } export async function get_variables_by_scope( scope: VariableScope, scopeId: string | null ): Promise { - return [...variables.values()].filter( - (v) => v.scope === scope && v.scopeId === scopeId - ); -} - -export async function get_global_variables(): Promise { - return get_variables_by_scope("global", null); -} - -export async function get_workspace_variables( - workspaceId: string -): Promise { - return get_variables_by_scope("workspace", workspaceId); -} - -export async function get_collection_variables( - collectionId: string -): Promise { - return get_variables_by_scope("collection", collectionId); -} - -export async function get_request_variables( - requestId: string -): Promise { - return get_variables_by_scope("request", requestId); + switch (scope) { + case "global": + return get_global_variables(); + case "workspace": + return scopeId ? get_workspace_variables(scopeId) : []; + case "collection": + return scopeId ? get_collection_variables(scopeId) : []; + case "request": + return scopeId ? get_request_variables(scopeId) : []; + default: + return []; + } } export async function get_resolved_variables( @@ -95,83 +118,64 @@ export async function get_resolved_variables( collectionId: string | null, requestId: string | null ): Promise { - const resolved: Map = new Map(); - - const globalVars = await get_global_variables(); - for (const v of globalVars) { - resolved.set(v.name, { - name: v.name, - value: v.value, - scope: v.scope, - isSecret: v.isSecret, - }); - } - - const workspaceVars = await get_workspace_variables(workspaceId); - for (const v of workspaceVars) { - resolved.set(v.name, { - name: v.name, - value: v.value, - scope: v.scope, - isSecret: v.isSecret, - }); - } - - if (collectionId) { - const collectionVars = await get_collection_variables(collectionId); - for (const v of collectionVars) { - resolved.set(v.name, { - name: v.name, - value: v.value, - scope: v.scope, - isSecret: v.isSecret, - }); - } - } - - if (requestId) { - const requestVars = await get_request_variables(requestId); - for (const v of requestVars) { - resolved.set(v.name, { - name: v.name, - value: v.value, - scope: v.scope, - isSecret: v.isSecret, - }); - } - } - - return [...resolved.values()]; + const vars = await invoke("get_resolved_variables", { + workspaceId, + collectionId, + requestId, + }); + return vars.map(toResolvedVariable); } export async function get_variable(id: string): Promise { - return variables.get(id); + try { + const v = await invoke("get_variable", { id }); + return toVariable(v); + } catch { + return undefined; + } } export async function create_variable( variable: Omit ): Promise { - const newVariable: Variable = { - ...variable, - id: crypto.randomUUID(), + const input: CreateVariableInput = { + name: variable.name, + value: variable.value, + scope: variable.scope, + scope_id: variable.scopeId, + is_secret: variable.isSecret, + description: variable.description ?? null, }; - variables.set(newVariable.id, newVariable); - return newVariable; + const v = await invoke("create_variable", { input }); + return toVariable(v); } export async function update_variable( id: string, updates: Partial> ): Promise { - const variable = variables.get(id); - if (!variable) return false; - - Object.assign(variable, updates); - return true; + try { + const input: UpdateVariableInput = { + id, + name: updates.name, + value: updates.value, + is_secret: updates.isSecret, + description: updates.description, + }; + await invoke("update_variable", { input }); + return true; + } catch { + return false; + } } export async function delete_variable(id: string): Promise { - return variables.delete(id); + try { + await invoke("delete_variable", { id }); + return true; + } catch { + return false; + } } export function interpolate_variables( diff --git a/src/routes/workspaces/+page.svelte b/src/routes/workspaces/+page.svelte index 7e0c0c4..bc60c9a 100644 --- a/src/routes/workspaces/+page.svelte +++ b/src/routes/workspaces/+page.svelte @@ -51,6 +51,8 @@ let selectedWorkspace = $state(null); let workspaceName = $state(""); let workspaceDescription = $state(""); + let workspaceTags = $state([]); + let tagInput = $state(""); let settingsOpen = $state(false); let variablesOpen = $state(false); @@ -64,6 +66,8 @@ selectedWorkspace = null; workspaceName = ""; workspaceDescription = ""; + workspaceTags = []; + tagInput = ""; dialogOpen = true; } @@ -72,9 +76,23 @@ selectedWorkspace = workspace; workspaceName = workspace.Name ?? ""; workspaceDescription = workspace.Description ?? ""; + workspaceTags = [...(workspace.Tags ?? [])]; + tagInput = ""; dialogOpen = true; } + function addTag() { + const trimmed = tagInput.trim(); + if (trimmed && !workspaceTags.includes(trimmed)) { + workspaceTags = [...workspaceTags, trimmed]; + tagInput = ""; + } + } + + function removeTag(tag: string) { + workspaceTags = workspaceTags.filter((t) => t !== tag); + } + function openDuplicateDialog(workspace: Workspace) { workspaceToDuplicate = workspace; duplicateDialogOpen = true; @@ -94,13 +112,14 @@ await create_workspace({ name: workspaceName, description: workspaceDescription, - tags: [], + tags: workspaceTags, }); } else if (selectedWorkspace != null) { await update_workspace({ id: selectedWorkspace.Id, name: workspaceName, description: workspaceDescription, + tags: workspaceTags, }); } @@ -283,6 +302,40 @@ bind:value={workspaceDescription} /> +
+ +
+
+ + e.key === "Enter" && (e.preventDefault(), addTag())} + /> + +
+ {#if workspaceTags.length > 0} +
+ {#each workspaceTags as tag} + + {tag} + + + {/each} +
+ {/if} +
+