diff --git a/Cargo.lock b/Cargo.lock index a0e8061..61fe8e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -128,14 +128,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5ec52ba94edeed950e4a41f75d35376df196e8cb04437f7280a5aa49f20f796" dependencies = [ "arrow-arith", - "arrow-array", - "arrow-buffer", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", "arrow-cast", - "arrow-data", - "arrow-ipc", + "arrow-data 54.3.1", + "arrow-ipc 54.3.1", "arrow-ord", "arrow-row", - "arrow-schema", + "arrow-schema 54.3.1", "arrow-select", "arrow-string", ] @@ -146,10 +146,10 @@ version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc766fdacaf804cb10c7c70580254fcdb5d55cdfda2bc57b02baf5223a3af9e" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", "chrono", "num", ] @@ -161,9 +161,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a12fcdb3f1d03f69d3ec26ac67645a8fe3f878d77b5ebb0b15d64a116c212985" dependencies = [ "ahash", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", + "chrono", + "half", + "hashbrown 0.15.5", + "num", +] + +[[package]] +name = "arrow-array" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70732f04d285d49054a48b72c54f791bb3424abae92d27aafdf776c98af161c8" +dependencies = [ + "ahash", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "chrono", "half", "hashbrown 0.15.5", @@ -181,16 +197,27 @@ dependencies = [ "num", ] +[[package]] +name = "arrow-buffer" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "169b1d5d6cb390dd92ce582b06b23815c7953e9dfaaea75556e89d890d19993d" +dependencies = [ + "bytes", + "half", + "num", +] + [[package]] name = "arrow-cast" version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ede6175fbc039dfc946a61c1b6d42fd682fcecf5ab5d148fbe7667705798cac9" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", "arrow-select", "atoi", "base64", @@ -207,8 +234,20 @@ version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61cfdd7d99b4ff618f167e548b2411e5dd2c98c0ddebedd7df433d34c20a4429" dependencies = [ - "arrow-buffer", - "arrow-schema", + "arrow-buffer 54.3.1", + "arrow-schema 54.3.1", + "half", + "num", +] + +[[package]] +name = "arrow-data" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de1ce212d803199684b658fc4ba55fb2d7e87b213de5af415308d2fee3619c2" +dependencies = [ + "arrow-buffer 55.2.0", + "arrow-schema 55.2.0", "half", "num", ] @@ -219,11 +258,24 @@ version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62ff528658b521e33905334723b795ee56b393dbe9cf76c8b1f64b648c65a60c" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", - "flatbuffers", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", + "flatbuffers 24.12.23", +] + +[[package]] +name = "arrow-ipc" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9ea5967e8b2af39aff5d9de2197df16e305f47f404781d3230b2dc672da5d92" +dependencies = [ + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", + "flatbuffers 25.12.19", ] [[package]] @@ -232,10 +284,10 @@ version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0a3334a743bd2a1479dbc635540617a3923b4b2f6870f37357339e6b5363c21" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", "arrow-select", ] @@ -245,10 +297,10 @@ version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d1d7a7291d2c5107e92140f75257a99343956871f3d3ab33a7b41532f79cb68" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", "half", ] @@ -258,6 +310,12 @@ version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cfaf5e440be44db5413b75b72c2a87c1f8f0627117d110264048f2969b99e9" +[[package]] +name = "arrow-schema" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7686986a3bf2254c9fb130c623cdcb2f8e1f15763e7c71c310f0834da3d292" + [[package]] name = "arrow-select" version = "54.3.1" @@ -265,10 +323,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69efcd706420e52cd44f5c4358d279801993846d1c2a8e52111853d61d55a619" dependencies = [ "ahash", - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", "num", ] @@ -278,10 +336,10 @@ version = "54.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a21546b337ab304a32cfc0770f671db7411787586b45b78b4593ae78e64e2b03" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 54.3.1", + "arrow-buffer 54.3.1", + "arrow-data 54.3.1", + "arrow-schema 54.3.1", "arrow-select", "memchr", "num", @@ -305,6 +363,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atoi" version = "2.0.0" @@ -326,6 +395,28 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.22.1" @@ -353,6 +444,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -384,6 +484,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -407,6 +509,7 @@ checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "num-traits", + "serde", "windows-link", ] @@ -465,6 +568,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -480,6 +592,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "console" version = "0.15.11" @@ -665,6 +787,50 @@ dependencies = [ "typenum", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -744,6 +910,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -819,6 +991,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.12.1", + "rustc_version", +] + [[package]] name = "flate2" version = "1.1.9" @@ -865,6 +1047,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.32" @@ -887,6 +1075,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -907,6 +1106,7 @@ checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -985,7 +1185,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -1004,6 +1204,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -1025,12 +1231,41 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hotdata" +version = "0.1.0" +source = "git+https://github.com/hotdata-dev/sdk-rust?rev=8d4018fb899ba52228db44eaffa6caa0eb5b603f#8d4018fb899ba52228db44eaffa6caa0eb5b603f" +dependencies = [ + "arrow-array 55.2.0", + "arrow-ipc 55.2.0", + "arrow-schema 55.2.0", + "async-trait", + "bytes", + "futures-core", + "log", + "reqwest 0.13.4", + "serde", + "serde_json", + "serde_repr", + "serde_with", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "hotdata-cli" version = "0.4.0" dependencies = [ "anstyle", "arrow", + "async-trait", "base64", "clap", "clap_complete", @@ -1038,6 +1273,7 @@ dependencies = [ "directories", "dotenvy", "flate2", + "hotdata", "indicatif", "inquire", "lzma-rs", @@ -1046,7 +1282,7 @@ dependencies = [ "open", "rand 0.8.6", "rayon", - "reqwest", + "reqwest 0.13.4", "self_update", "semver", "serde", @@ -1059,6 +1295,7 @@ dependencies = [ "tar", "tempfile", "tiny_http", + "tokio", "toml", "urlencoding", ] @@ -1299,6 +1536,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1320,6 +1563,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1396,6 +1650,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -1559,6 +1872,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 = "minimal-lexical" version = "0.2.1" @@ -1693,6 +2016,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1906,6 +2235,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1991,6 +2326,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -2140,6 +2476,26 @@ dependencies = [ "thiserror", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "regex" version = "1.12.3" @@ -2174,6 +2530,47 @@ name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -2192,12 +2589,14 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -2205,14 +2604,15 @@ dependencies = [ "tokio", "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -2276,6 +2676,7 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -2284,6 +2685,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -2294,12 +2707,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2317,6 +2758,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2326,6 +2776,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2377,7 +2851,7 @@ dependencies = [ "log", "quick-xml", "regex", - "reqwest", + "reqwest 0.12.28", "self-replace", "semver", "serde_json", @@ -2434,6 +2908,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2455,13 +2940,45 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -2522,6 +3039,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" @@ -2733,6 +3266,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-keccak" version = "2.0.2" @@ -2791,9 +3355,21 @@ dependencies = [ "parking_lot", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" @@ -2854,7 +3430,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned", "toml_datetime", @@ -2944,6 +3520,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3038,6 +3620,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3143,11 +3735,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -3156,7 +3761,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.12.1", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -3180,6 +3785,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -3205,6 +3819,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3540,7 +4163,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -3571,7 +4194,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.12.1", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3590,7 +4213,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", diff --git a/Cargo.toml b/Cargo.toml index c5f7c69..e1b1a68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,23 @@ name = "hotdata" path = "src/main.rs" [dependencies] +# Hotdata Rust SDK. The CLI's sync wrapper (src/sdk.rs) drives this async SDK +# behind a shared multi-thread tokio runtime. Pinned to the rev that adds the +# CLI-consumption surface (attribution client_id, async submit_query, streaming +# upload_stream) — merged via hotdata-dev/sdk-rust#32. +hotdata = { git = "https://github.com/hotdata-dev/sdk-rust", rev = "8d4018fb899ba52228db44eaffa6caa0eb5b603f", features = ["arrow"] } +# Shared multi-thread runtime for the sync wrapper; block_on is called +# concurrently from rayon worker threads (see src/indexes.rs). +tokio = { version = "1", features = ["rt-multi-thread"] } +# CliTokenProvider implements the SDK's #[async_trait] BearerTokenProvider. +async-trait = "0.1" anstyle = "1.0.13" clap = { version = "4", features = ["derive"] } directories = "6" -reqwest = { version = "0.12", features = ["blocking", "json"] } +# Matches the SDK's reqwest 0.13 so the sdk seam can name the `reqwest::Client` +# type backing `Configuration.client`; `blocking` serves the CLI's own +# synchronous paths (PKCE/token mints in jwt.rs + the streaming /files upload). +reqwest = { version = "0.13", features = ["blocking", "json"] } rayon = "1.10" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src/api.rs b/src/api.rs deleted file mode 100644 index 4141766..0000000 --- a/src/api.rs +++ /dev/null @@ -1,877 +0,0 @@ -use crate::auth; -use crate::config; -use crate::util; -use crossterm::style::Stylize; -use serde::de::DeserializeOwned; -use std::sync::{Arc, Mutex}; -use std::time::Duration; - -/// Mints a fresh bearer token on demand. Returns `None` if no fresh token -/// could be obtained (e.g. the refresh token is dead and there's no API key -/// to re-mint from). Must be `Send + Sync` because `ApiClient` is shared -/// across rayon worker threads (see `indexes.rs`). -pub type TokenRefresher = Arc Option + Send + Sync>; - -/// Cap on any single HTTP request. Connection create + synchronous schema -/// discovery against a slow remote catalog can take well over a minute, so -/// this needs to be generous; 5 minutes leaves headroom while still bounding -/// the worst case if the server genuinely hangs. -const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); - -/// TCP keepalive cadence. Without this, macOS will drop a TCP connection -/// that has been quiet (e.g. while the server is doing slow synchronous -/// work) and reqwest surfaces it as "error sending request" even though the -/// request itself completed server-side. -const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); - -fn build_http_client() -> reqwest::blocking::Client { - reqwest::blocking::Client::builder() - .timeout(HTTP_REQUEST_TIMEOUT) - .tcp_keepalive(TCP_KEEPALIVE_INTERVAL) - .build() - .expect("reqwest blocking client should always build with these defaults") -} - -/// Client used only for streaming file uploads. Deliberately has **no** -/// request timeout: an upload's duration scales with file size and the -/// user's uplink (a 10 GB parquet on a normal connection takes far longer -/// than the 300s `HTTP_REQUEST_TIMEOUT` that's sized for slow server-side -/// work), so a wall-clock cap would abort healthy-but-slow transfers. TCP -/// keepalive is kept so a genuinely dead peer is still reaped by the OS; a -/// live-but-slow upload runs to completion, and the user can Ctrl-C if it -/// truly stalls. -fn build_upload_client() -> reqwest::blocking::Client { - reqwest::blocking::Client::builder() - .tcp_keepalive(TCP_KEEPALIVE_INTERVAL) - .build() - .expect("reqwest blocking client should always build with these defaults") -} - -#[derive(Clone)] -pub struct ApiClient { - client: reqwest::blocking::Client, - /// The current bearer token. Wrapped so it can be refreshed in place - /// through a `&self` borrow (every request method takes `&self`), and - /// `Arc>` rather than `RefCell` so the client stays `Send + - /// Sync` for the rayon-parallel paths and `#[derive(Clone)]` keeps - /// clones sharing the same refreshed token. - token: Arc>, - /// How to obtain a fresh token when the current one is rejected. - refresh: TokenRefresher, - pub api_url: String, - workspace_id: Option, - sandbox_id: Option, - database_id: Option, -} - -impl ApiClient { - /// Create a new API client. Loads config, pre-flights a JWT session. - /// Pass `workspace_id` for endpoints that require it, or `None` for - /// workspace-less endpoints. - pub fn new(workspace_id: Option<&str>) -> Self { - let profile_config = match config::load("default") { - Ok(c) => c, - Err(e) => { - eprintln!("{e}"); - std::process::exit(1); - } - }; - - // Auth source precedence: - // - // 1. `HOTDATA_DATABASE_TOKEN` env var — a `databases run` child - // is executing with the parent's credentials scrubbed and a - // database-scoped JWT injected. Refresh in-memory via - // `HOTDATA_DATABASE_REFRESH_TOKEN` near expiry; never write - // to disk (the child's FS may not be writable). - // 2. `HOTDATA_SANDBOX_TOKEN` env var — a `sandbox run` child - // is executing with the parent's credentials scrubbed. - // Refresh in-memory via `HOTDATA_SANDBOX_REFRESH_TOKEN` if - // the JWT is close to expiry; never write to disk (the - // child's FS may not be writable). - // 3. `~/.hotdata/sandbox_session.json` — the user ran - // `hotdata sandbox set ` (or `sandbox new` / `sandbox - // run` in the parent shell). The sandbox JWT is the active - // bearer for *every* command until `sandbox set` (with no - // id) clears the file. - // 4. `~/.hotdata/session.json` + optional api_key fallback — - // normal user-scoped CLI session. - let api_url = profile_config.api_url.to_string(); - let access_token = if std::env::var("HOTDATA_DATABASE_TOKEN").is_ok() { - match crate::database_session::refresh_from_env(&api_url) { - Some(t) => t, - None => { - eprintln!("{}", "error: HOTDATA_DATABASE_TOKEN is empty".red()); - std::process::exit(1); - } - } - } else if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { - match crate::sandbox_session::refresh_from_env(&api_url) { - Some(t) => t, - None => { - eprintln!("{}", "error: HOTDATA_SANDBOX_TOKEN is empty".red()); - std::process::exit(1); - } - } - } else if crate::sandbox_session::load().is_some() { - match crate::sandbox_session::ensure_access_token(&api_url) { - Some(t) => t, - None => { - eprintln!("{}", "error: sandbox session expired".red()); - eprintln!( - "Run {} to clear it, or {} to re-mint.", - "hotdata sandbox set".cyan(), - "hotdata sandbox set ".cyan(), - ); - std::process::exit(1); - } - } - } else { - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - - // Pre-flight: return the cached JWT if valid, refresh it if - // close to expiry, or mint a new one from the API key. The - // returned string is a JWT — that's what we send on the wire. - match crate::jwt::ensure_access_token(&profile_config, api_key_fallback) { - Ok(t) => t, - Err(e) => { - eprintln!("{}", format!("error: {e}").red()); - eprintln!( - "Run {} to log in, or pass --api-key.", - "hotdata auth".cyan() - ); - std::process::exit(1); - } - } - }; - - // Refresher used when a request comes back 401: reload config (to - // pick up a session that `ensure_access_token` may have just - // persisted) and re-run the same auth-source precedence, best-effort. - let refresh: TokenRefresher = Arc::new(|| { - let pc = config::load("default").ok()?; - resolve_fresh_token(&pc) - }); - - Self { - client: build_http_client(), - token: Arc::new(Mutex::new(access_token)), - refresh, - api_url: profile_config.api_url.to_string(), - workspace_id: workspace_id.map(String::from), - sandbox_id: std::env::var("HOTDATA_SANDBOX").ok().or_else(|| { - if crate::sandbox::find_sandbox_run_ancestor().is_some() { - eprintln!("error: sandbox has been lost -- restart the process"); - std::process::exit(1); - } - profile_config.sandbox - }), - database_id: std::env::var("HOTDATA_DATABASE").ok().or_else(|| { - workspace_id.and_then(|ws| crate::config::load_current_database("default", ws)) - }), - } - } - - /// Override the database ID for a single query without touching config. - pub fn with_database(mut self, database_id: &str) -> Self { - self.database_id = Some(database_id.to_string()); - self - } - - pub fn workspace_id(&self) -> Option<&str> { - self.workspace_id.as_deref() - } - - /// Test-only client (no config load). Used with a local mock HTTP server. - /// The refresher returns `None`, so 401s are not retried — matching the - /// behavior of tests that don't exercise the refresh path. - #[cfg(test)] - pub(crate) fn test_new(api_url: &str, api_key: &str, workspace_id: Option<&str>) -> Self { - Self::test_new_with_refresh(api_url, api_key, workspace_id, Arc::new(|| None)) - } - - /// Test-only client with an injectable token refresher, for exercising the - /// 401-retry path without touching real config or the JWT machinery. - #[cfg(test)] - pub(crate) fn test_new_with_refresh( - api_url: &str, - api_key: &str, - workspace_id: Option<&str>, - refresh: TokenRefresher, - ) -> Self { - Self { - client: build_http_client(), - token: Arc::new(Mutex::new(api_key.to_string())), - refresh, - api_url: api_url.to_string(), - workspace_id: workspace_id.map(String::from), - sandbox_id: None, - database_id: None, - } - } - - /// Prints an error for a non-2xx response and exits. On 4xx, first re-probes - /// the API key: if it's actually invalid, a clear re-auth hint is shown - /// instead of whatever cryptic body the primary endpoint returned. - fn fail_response(&self, status: reqwest::StatusCode, body: String) -> ! { - let auth_status = if status.is_client_error() { - config::load("default") - .ok() - .map(|pc| auth::check_status(&pc)) - } else { - None - }; - eprintln!( - "{}", - format_fail_message(status, &body, auth_status.as_ref()).red() - ); - std::process::exit(1); - } - - fn build_request( - &self, - method: reqwest::Method, - url: &str, - ) -> reqwest::blocking::RequestBuilder { - let bearer = self.token.lock().expect("token mutex poisoned").clone(); - let mut req = self - .client - .request(method, url) - .header("Authorization", format!("Bearer {bearer}")); - if let Some(ref ws) = self.workspace_id { - req = req.header("X-Workspace-Id", ws); - } - if let Some(ref sid) = self.sandbox_id { - // Send both headers during the session→sandbox migration window. - req = req.header("X-Session-Id", sid); - req = req.header("X-Sandbox-Id", sid); - } - if let Some(ref db_id) = self.database_id { - req = req.header("X-Database-Id", db_id); - } - req - } - - /// Send via `util::send_debug` and unwrap connection errors with the - /// CLI's standard "error connecting" exit. All public HTTP methods - /// route through here so debug logging is uniform. - fn send( - &self, - builder: reqwest::blocking::RequestBuilder, - body_for_log: Option<&serde_json::Value>, - ) -> (reqwest::StatusCode, String) { - match util::send_debug(&self.client, builder, body_for_log) { - Ok(pair) => pair, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - } - } - - /// Mint a fresh bearer and swap it in. Returns whether a new token was - /// obtained — `false` means the refresher gave up, so the caller should - /// surface the original failure rather than pointlessly retrying. - fn refresh_token(&self) -> bool { - match (self.refresh)() { - Some(new) => { - *self.token.lock().expect("token mutex poisoned") = new; - true - } - None => false, - } - } - - /// Send a request and, if the server rejects the bearer with 401, mint a - /// fresh token and retry exactly once. `build` reconstructs the request - /// from scratch on each attempt so the retry picks up the refreshed bearer - /// (the Authorization header is baked into an already-built request and - /// can't be mutated). Streaming uploads can't use this — their body is - /// consumed on the first send and is not replayable. - fn send_with_retry( - &self, - build: impl Fn() -> reqwest::blocking::RequestBuilder, - body_for_log: Option<&serde_json::Value>, - ) -> (reqwest::StatusCode, String) { - let (status, body) = self.send(build(), body_for_log); - if status == reqwest::StatusCode::UNAUTHORIZED && self.refresh_token() { - return self.send(build(), body_for_log); - } - (status, body) - } - - fn parse_json(body: &str) -> T { - match serde_json::from_str(body) { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } - } - } - - /// GET request with query parameters, returns parsed response. - /// Parameters with `None` values are omitted. - pub fn get_with_params( - &self, - path: &str, - params: &[(&str, Option)], - ) -> T { - let filtered: Vec<(&str, &String)> = params - .iter() - .filter_map(|(k, v)| v.as_ref().map(|val| (*k, val))) - .collect(); - let url = format!("{}{path}", self.api_url); - let (status, body) = self.send_with_retry( - || { - self.build_request(reqwest::Method::GET, &url) - .query(&filtered) - }, - None, - ); - if !status.is_success() { - self.fail_response(status, body); - } - Self::parse_json(&body) - } - - /// GET request, returns parsed response. - pub fn get(&self, path: &str) -> T { - let url = format!("{}{path}", self.api_url); - let (status, body) = - self.send_with_retry(|| self.build_request(reqwest::Method::GET, &url), None); - if !status.is_success() { - self.fail_response(status, body); - } - Self::parse_json(&body) - } - - /// GET request; returns `None` on HTTP 404. Other status codes use the same handling as - /// [`Self::get`]. Used when probing many paths where a missing resource is normal. - pub fn get_none_if_not_found(&self, path: &str) -> Option { - let url = format!("{}{path}", self.api_url); - let (status, body) = - self.send_with_retry(|| self.build_request(reqwest::Method::GET, &url), None); - if status == reqwest::StatusCode::NOT_FOUND { - return None; - } - if !status.is_success() { - self.fail_response(status, body); - } - Some(Self::parse_json(&body)) - } - - /// POST request with JSON body, returns parsed response. - pub fn post(&self, path: &str, body: &serde_json::Value) -> T { - let url = format!("{}{path}", self.api_url); - let (status, resp_body) = self.send_with_retry( - || self.build_request(reqwest::Method::POST, &url).json(body), - Some(body), - ); - if !status.is_success() { - self.fail_response(status, resp_body); - } - Self::parse_json(&resp_body) - } - - /// GET request, exits only on connection error, returns raw (status, body). - /// Use for best-effort endpoints (e.g. health checks) where the caller wants - /// to handle non-2xx responses gracefully instead of aborting. - pub fn get_raw(&self, path: &str) -> (reqwest::StatusCode, String) { - let url = format!("{}{path}", self.api_url); - self.send_with_retry(|| self.build_request(reqwest::Method::GET, &url), None) - } - - /// GET with a custom Accept header; returns raw bytes instead of decoded text. - /// Used for binary result formats such as Arrow IPC streams. - pub fn get_bytes(&self, path: &str, accept: &str) -> (reqwest::StatusCode, Vec) { - let url = format!("{}{path}", self.api_url); - let send = |client: &reqwest::blocking::Client, c: &Self| { - let req = c - .build_request(reqwest::Method::GET, &url) - .header("Accept", accept); - match util::send_debug_bytes(client, req) { - Ok(pair) => pair, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - } - }; - let (status, bytes) = send(&self.client, self); - if status == reqwest::StatusCode::UNAUTHORIZED && self.refresh_token() { - return send(&self.client, self); - } - (status, bytes) - } - - /// POST request with JSON body, exits on error, returns raw (status, body). - pub fn post_raw(&self, path: &str, body: &serde_json::Value) -> (reqwest::StatusCode, String) { - let url = format!("{}{path}", self.api_url); - self.send_with_retry( - || self.build_request(reqwest::Method::POST, &url).json(body), - Some(body), - ) - } - - /// DELETE request, exits on connection error, returns raw (status, body). - pub fn delete_raw(&self, path: &str) -> (reqwest::StatusCode, String) { - let url = format!("{}{path}", self.api_url); - self.send_with_retry(|| self.build_request(reqwest::Method::DELETE, &url), None) - } - - /// PATCH request with JSON body, returns parsed response. - pub fn patch(&self, path: &str, body: &serde_json::Value) -> T { - let url = format!("{}{path}", self.api_url); - let (status, resp_body) = self.send_with_retry( - || self.build_request(reqwest::Method::PATCH, &url).json(body), - Some(body), - ); - if !status.is_success() { - self.fail_response(status, resp_body); - } - Self::parse_json(&resp_body) - } - - /// PUT request with JSON body, returns parsed response. - pub fn put(&self, path: &str, body: &serde_json::Value) -> T { - let url = format!("{}{path}", self.api_url); - let (status, resp_body) = self.send_with_retry( - || self.build_request(reqwest::Method::PUT, &url).json(body), - Some(body), - ); - if !status.is_success() { - self.fail_response(status, resp_body); - } - Self::parse_json(&resp_body) - } - - /// POST with a custom request body (for file uploads). Returns raw status and body. - /// - /// Unlike the other methods this does **not** retry on 401: the body is a - /// one-shot stream that's consumed on send and can't be replayed. A large - /// upload is exactly the case where the token may expire mid-flight, but - /// the failure that matters surfaces on the *next* request (e.g. the load - /// POST), which does retry. See `databases::tables_load`. - pub fn post_body( - &self, - path: &str, - content_type: &str, - reader: R, - content_length: Option, - ) -> (reqwest::StatusCode, String) { - let url = format!("{}{path}", self.api_url); - let mut req = self - .build_request(reqwest::Method::POST, &url) - .header("Content-Type", content_type); - if let Some(len) = content_length { - req = req.header("Content-Length", len); - } - let req = req.body(reqwest::blocking::Body::new(reader)); - // Execute on the upload client (no request timeout) rather than the - // default 300s client — `build_request`'s originating client is - // irrelevant once the request is built, since the executing client's - // timeout is what applies. Body is an opaque stream, so pass `None` - // for logging; headers (including the masked Authorization) still log. - let upload_client = build_upload_client(); - match util::send_debug(&upload_client, req, None) { - Ok(pair) => pair, - Err(e) => { - eprintln!("error connecting to API: {e}"); - std::process::exit(1); - } - } - } -} - -/// Best-effort re-resolution of the bearer token, mirroring the auth-source -/// precedence in [`ApiClient::new`] but returning `None` on failure instead -/// of exiting. Used by the 401-retry refresher: at refresh time we're already -/// past startup, so a failure just means "couldn't refresh, surface the -/// original error" rather than a fatal startup diagnostic. -fn resolve_fresh_token(profile_config: &config::ProfileConfig) -> Option { - let api_url = profile_config.api_url.to_string(); - if std::env::var("HOTDATA_DATABASE_TOKEN").is_ok() { - crate::database_session::refresh_from_env(&api_url) - } else if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { - crate::sandbox_session::refresh_from_env(&api_url) - } else if crate::sandbox_session::load().is_some() { - crate::sandbox_session::ensure_access_token(&api_url) - } else { - let api_key_fallback = profile_config - .api_key - .as_deref() - .filter(|k| !k.is_empty() && *k != "PLACEHOLDER"); - crate::jwt::ensure_access_token(profile_config, api_key_fallback).ok() - } -} - -/// Decide what error text to print for a failed response. Pulled out as a pure -/// function so the 4xx-to-re-auth-hint logic can be unit-tested without -/// making real HTTP calls or touching `std::process::exit`. -fn format_fail_message( - status: reqwest::StatusCode, - body: &str, - auth_status: Option<&auth::AuthStatus>, -) -> String { - if status.is_client_error() - && let Some(auth::AuthStatus::Invalid(_)) = auth_status - { - return "error: API key is invalid. Run 'hotdata auth login' (or 'hotdata auth') to re-authenticate.".to_string(); - } - util::api_error(body.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - use auth::AuthStatus; - use serde::Deserialize; - - #[derive(Deserialize)] - struct Probe { - n: i32, - } - - #[test] - fn get_none_if_not_found_returns_none_on_404() { - let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/missing") - .match_header("Authorization", "Bearer test-key") - .with_status(404) - .create(); - - let api = ApiClient::test_new(&server.url(), "test-key", None); - let got: Option = api.get_none_if_not_found("/missing"); - assert!(got.is_none()); - mock.assert(); - } - - #[test] - fn delete_raw_returns_status_and_body() { - let mut server = mockito::Server::new(); - let mock = server - .mock("DELETE", "/widgets/abc") - .match_header("Authorization", "Bearer test-key") - .with_status(204) - .with_body("") - .create(); - - let api = ApiClient::test_new(&server.url(), "test-key", None); - let (status, body) = api.delete_raw("/widgets/abc"); - assert_eq!(status.as_u16(), 204); - assert!(body.is_empty()); - mock.assert(); - } - - #[test] - fn delete_raw_surfaces_error_body_on_4xx() { - let mut server = mockito::Server::new(); - let mock = server - .mock("DELETE", "/widgets/missing") - .with_status(404) - .with_body(r#"{"error":{"message":"not found"}}"#) - .create(); - - let api = ApiClient::test_new(&server.url(), "test-key", None); - let (status, body) = api.delete_raw("/widgets/missing"); - assert_eq!(status.as_u16(), 404); - assert!(body.contains("not found")); - mock.assert(); - } - - #[test] - fn get_none_if_not_found_returns_some_on_200() { - let mut server = mockito::Server::new(); - let mock = server - .mock("GET", "/ok") - .match_header("Authorization", "Bearer test-key") - .match_header("X-Workspace-Id", "ws-1") - .with_status(200) - .with_body(r#"{"n":7}"#) - .create(); - - let api = ApiClient::test_new(&server.url(), "test-key", Some("ws-1")); - let got: Option = api.get_none_if_not_found("/ok"); - assert_eq!(got.unwrap().n, 7); - mock.assert(); - } - - #[test] - fn format_fail_message_401_with_invalid_key_shows_reauth_hint() { - let msg = format_fail_message( - reqwest::StatusCode::UNAUTHORIZED, - "", - Some(&AuthStatus::Invalid(401)), - ); - assert!(msg.contains("API key is invalid")); - assert!(msg.contains("hotdata auth login") || msg.contains("hotdata auth")); - } - - #[test] - fn format_fail_message_404_with_invalid_key_shows_reauth_hint() { - // This is the user-reported scenario: the server masks an auth failure - // behind a 404 with an empty body. The re-auth probe catches it. - let msg = format_fail_message( - reqwest::StatusCode::NOT_FOUND, - "", - Some(&AuthStatus::Invalid(401)), - ); - assert!(msg.contains("API key is invalid"), "got: {msg}"); - } - - #[test] - fn format_fail_message_404_with_valid_key_shows_real_error() { - // If the auth probe says the key is fine, surface the upstream body. - let body = r#"{"error":{"message":"Query run 'qrun_notreal' not found"}}"#; - let msg = format_fail_message( - reqwest::StatusCode::NOT_FOUND, - body, - Some(&AuthStatus::Authenticated), - ); - assert!(!msg.contains("API key is invalid")); - assert!(msg.contains("Query run 'qrun_notreal' not found")); - } - - #[test] - fn format_fail_message_400_with_valid_key_shows_real_error() { - let body = r#"{"error":{"message":"invalid_sql"}}"#; - let msg = format_fail_message( - reqwest::StatusCode::BAD_REQUEST, - body, - Some(&AuthStatus::Authenticated), - ); - assert_eq!(msg, "invalid_sql"); - } - - #[test] - fn format_fail_message_5xx_never_shows_reauth_hint() { - // 5xx is not a client error — the auth probe is not even run, so - // `auth_status` is None from the caller and we just surface the body. - let msg = format_fail_message( - reqwest::StatusCode::INTERNAL_SERVER_ERROR, - "server exploded", - None, - ); - assert!(!msg.contains("API key is invalid")); - assert_eq!(msg, "server exploded"); - } - - #[test] - fn format_fail_message_4xx_connection_error_on_probe_falls_through() { - // If the probe itself couldn't reach the API, we can't claim the key - // is invalid — surface the original body instead. - let body = r#"{"error":{"message":"forbidden"}}"#; - let msg = format_fail_message( - reqwest::StatusCode::FORBIDDEN, - body, - Some(&AuthStatus::ConnectionError("tcp reset".to_string())), - ); - assert!(!msg.contains("API key is invalid")); - assert_eq!(msg, "forbidden"); - } - - #[test] - fn format_fail_message_4xx_no_probe_result_falls_through() { - // Caller couldn't load config (None) — still surface the upstream error. - let body = "plain body"; - let msg = format_fail_message(reqwest::StatusCode::NOT_FOUND, body, None); - assert!(!msg.contains("API key is invalid")); - assert_eq!(msg, "plain body"); - } - - #[test] - fn post_raw_retries_once_with_refreshed_token_after_401() { - let mut server = mockito::Server::new(); - // First attempt: stale bearer is rejected. - let stale = server - .mock("POST", "/load") - .match_header("Authorization", "Bearer stale-token") - .with_status(401) - .with_body("Invalid api key") - .create(); - // Retry: client must mint a fresh bearer and the server accepts it. - let fresh = server - .mock("POST", "/load") - .match_header("Authorization", "Bearer fresh-token") - .with_status(200) - .with_body(r#"{"ok":true}"#) - .create(); - - let api = ApiClient::test_new_with_refresh( - &server.url(), - "stale-token", - None, - std::sync::Arc::new(|| Some("fresh-token".to_string())), - ); - let (status, body) = api.post_raw("/load", &serde_json::json!({"upload_id": "u1"})); - - assert_eq!( - status.as_u16(), - 200, - "retry should surface the 200, got body: {body}" - ); - assert!(body.contains("\"ok\":true")); - stale.assert(); - fresh.assert(); - } - - #[test] - fn get_retries_once_with_refreshed_token_after_401() { - let mut server = mockito::Server::new(); - let stale = server - .mock("GET", "/ok") - .match_header("Authorization", "Bearer stale-token") - .with_status(401) - .with_body("Invalid api key") - .create(); - let fresh = server - .mock("GET", "/ok") - .match_header("Authorization", "Bearer fresh-token") - .with_status(200) - .with_body(r#"{"n":7}"#) - .create(); - - let api = ApiClient::test_new_with_refresh( - &server.url(), - "stale-token", - None, - std::sync::Arc::new(|| Some("fresh-token".to_string())), - ); - let got: Probe = api.get("/ok"); - assert_eq!(got.n, 7); - stale.assert(); - fresh.assert(); - } - - #[test] - fn does_not_retry_on_non_401() { - // A 500 is not an auth problem — the client must not refresh or retry. - let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/load") - .with_status(500) - .with_body("boom") - .expect(1) // exactly one request, no retry - .create(); - - let refreshed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let flag = refreshed.clone(); - let api = ApiClient::test_new_with_refresh( - &server.url(), - "stale-token", - None, - std::sync::Arc::new(move || { - flag.store(true, std::sync::atomic::Ordering::SeqCst); - Some("fresh-token".to_string()) - }), - ); - let (status, _) = api.post_raw("/load", &serde_json::json!({})); - assert_eq!(status.as_u16(), 500); - assert!( - !refreshed.load(std::sync::atomic::Ordering::SeqCst), - "refresher must not be called on a non-401 response" - ); - mock.assert(); - } - - #[test] - fn retries_at_most_once_then_surfaces_401() { - // Both attempts 401 → give up after a single retry (no infinite loop). - let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/load") - .with_status(401) - .with_body("Invalid api key") - .expect(2) // original + one retry, then stop - .create(); - - let api = ApiClient::test_new_with_refresh( - &server.url(), - "stale-token", - None, - std::sync::Arc::new(|| Some("still-bad-token".to_string())), - ); - let (status, body) = api.post_raw("/load", &serde_json::json!({})); - assert_eq!(status.as_u16(), 401); - assert!(body.contains("Invalid api key")); - mock.assert(); - } - - #[test] - fn does_not_retry_when_refresher_cannot_mint() { - // Refresher returns None (e.g. dead refresh token, no API key) → the - // original 401 is surfaced unchanged, with no second request. - let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/load") - .with_status(401) - .with_body("Invalid api key") - .expect(1) - .create(); - - let api = ApiClient::test_new_with_refresh( - &server.url(), - "stale-token", - None, - std::sync::Arc::new(|| None), - ); - let (status, _) = api.post_raw("/load", &serde_json::json!({})); - assert_eq!(status.as_u16(), 401); - mock.assert(); - } - - #[test] - fn post_body_does_not_retry_on_401() { - // Streaming uploads can't be replayed, so a 401 here is surfaced as-is - // and the refresher is never consulted. - let mut server = mockito::Server::new(); - let mock = server - .mock("POST", "/files") - .with_status(401) - .with_body("Invalid api key") - .expect(1) - .create(); - - let refreshed = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let flag = refreshed.clone(); - let api = ApiClient::test_new_with_refresh( - &server.url(), - "stale-token", - None, - std::sync::Arc::new(move || { - flag.store(true, std::sync::atomic::Ordering::SeqCst); - Some("fresh-token".to_string()) - }), - ); - let data = b"parquet-bytes".to_vec(); - let (status, _) = api.post_body( - "/files", - "application/octet-stream", - std::io::Cursor::new(data), - None, - ); - assert_eq!(status.as_u16(), 401); - assert!( - !refreshed.load(std::sync::atomic::Ordering::SeqCst), - "streaming upload must not trigger a token refresh/retry" - ); - mock.assert(); - } - - #[test] - fn format_fail_message_4xx_authenticated_probe_shows_server_message() { - // Valid key but a genuine client error — upstream message wins. - let body = r#"{"error":{"message":"workspace_not_found"}}"#; - let msg = format_fail_message( - reqwest::StatusCode::NOT_FOUND, - body, - Some(&AuthStatus::Authenticated), - ); - assert_eq!(msg, "workspace_not_found"); - } -} diff --git a/src/connections.rs b/src/connections.rs index 13af103..5fcefaa 100644 --- a/src/connections.rs +++ b/src/connections.rs @@ -1,4 +1,4 @@ -use crate::api::ApiClient; +use crate::sdk::{Api, ApiError, block, none_if_404}; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -34,19 +34,30 @@ impl Serialize for HealthStatus { } } -fn fetch_health(api: &ApiClient, connection_id: &str, show_spinner: bool) -> HealthStatus { +/// Render an [`ApiError`] the way the old raw `fetch_health` path did: the +/// server body through `api_error`, or the transport message verbatim. +fn error_text(e: ApiError) -> String { + match e { + ApiError::Status { body, .. } => crate::util::api_error(body), + ApiError::Transport(msg) => msg, + } +} + +fn fetch_health(api: &Api, connection_id: &str, show_spinner: bool) -> HealthStatus { let spinner = show_spinner.then(|| crate::util::spinner("Checking connection health...")); - let (status, body) = api.get_raw(&format!("/connections/{connection_id}/health")); + let result = block(api.client().connections().check_health(connection_id)); if let Some(s) = spinner { s.finish_and_clear(); } - if !status.is_success() { - return HealthStatus::Unavailable(crate::util::api_error(body)); - } - match serde_json::from_str::(&body) { - Ok(h) => HealthStatus::Available(h), - Err(e) => HealthStatus::Unavailable(format!("parse error: {e}")), + match result { + Ok(h) => HealthStatus::Available(HealthResponse { + connection_id: h.connection_id, + healthy: h.healthy, + latency_ms: Some(h.latency_ms.max(0) as u64), + error: h.error.flatten(), + }), + Err(e) => HealthStatus::Unavailable(error_text(e)), } } @@ -87,8 +98,18 @@ struct ConnectionTypeDetail { } pub fn types_list(workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let body: ListConnectionTypesResponse = api.get("/connection-types"); + let api = Api::new(Some(workspace_id)); + let resp = block(api.client().connection_types().list()).unwrap_or_else(|e| e.exit()); + let body = ListConnectionTypesResponse { + connection_types: resp + .connection_types + .into_iter() + .map(|t| ConnectionType { + name: t.name, + label: t.label, + }) + .collect(), + }; match format { "json" => println!( @@ -114,8 +135,17 @@ pub fn types_list(workspace_id: &str, format: &str) { } pub fn types_get(workspace_id: &str, name: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let detail: ConnectionTypeDetail = api.get(&format!("/connection-types/{name}")); + let api = Api::new(Some(workspace_id)); + let resp = block(api.client().connection_types().get(name)).unwrap_or_else(|e| e.exit()); + // The SDK models nullable fields as `Option>`; flatten and + // drop an explicit JSON `null` to match the old behavior (the old struct + // deserialized a missing/`null` field to `None`). + let detail = ConnectionTypeDetail { + name: resp.name, + label: resp.label, + config_schema: resp.config_schema.flatten().filter(|v| !v.is_null()), + auth: resp.auth.flatten().filter(|v| !v.is_null()), + }; match format { "json" => println!("{}", serde_json::to_string_pretty(&detail).unwrap()), @@ -162,32 +192,36 @@ struct ListResponse { /// If `name_or_id` looks like a raw connection ID (starts with "conn"), tries /// `GET /connections/{id}` directly first to avoid listing the full workspace. /// Falls back to listing and matching by name on a 404 or when given a plain name. -pub fn resolve_connection_id(api: &ApiClient, name_or_id: &str) -> String { +pub fn resolve_connection_id(api: &Api, name_or_id: &str) -> String { use crossterm::style::Stylize; if name_or_id.starts_with("conn") { - let (status, _) = api.get_raw(&format!("/connections/{name_or_id}")); - if status.is_success() { + // Existence probe: a 404 just means "not a raw id", fall through to the + // name/catalog lookup; any other error is fatal. + if none_if_404(block(api.client().connections().get(name_or_id))) + .unwrap_or_else(|e| e.exit()) + .is_some() + { return name_or_id.to_string(); } } // Before listing connections, check if the active database's catalog or name // matches — prefer it over any stale connection entry with the same name. - if let Some(ws) = api.workspace_id() { - if let Some(active_id) = crate::config::load_current_database("default", ws) { - if let Some(active_db) = api.get_none_if_not_found::(&format!("/databases/{active_id}")) { - if active_db.default_catalog.as_deref() == Some(name_or_id) - || active_db.name.as_deref() == Some(name_or_id) - { - return active_db.default_connection_id; - } - } - } + if let Some(ws) = api.workspace_id() + && let Some(active_id) = crate::config::load_current_database("default", ws) + && let Some(active_db) = none_if_404( + api.get_json::(&format!("/databases/{active_id}"), &[]), + ) + .unwrap_or_else(|e| e.exit()) + && (active_db.default_catalog.as_deref() == Some(name_or_id) + || active_db.name.as_deref() == Some(name_or_id)) + { + return active_db.default_connection_id; } - let body: ListResponse = api.get("/connections"); - if let Some(conn) = body + let resp = block(api.client().connections().list()).unwrap_or_else(|e| e.exit()); + if let Some(conn) = resp .connections .iter() .find(|c| c.id == name_or_id || c.name == name_or_id) @@ -208,14 +242,21 @@ pub fn resolve_connection_id(api: &ApiClient, name_or_id: &str) -> String { } pub fn get(workspace_id: &str, connection_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let is_table = format == "table"; let spinner = is_table.then(|| crate::util::spinner("Fetching connection...")); - let detail: ConnectionDetail = api.get(&format!("/connections/{connection_id}")); + let resp = block(api.client().connections().get(connection_id)).unwrap_or_else(|e| e.exit()); if let Some(s) = spinner { s.finish_and_clear(); } + let detail = ConnectionDetail { + id: resp.id, + name: resp.name, + source_type: resp.source_type, + table_count: resp.table_count.max(0) as u64, + synced_table_count: resp.synced_table_count.max(0) as u64, + }; let health = fetch_health(&api, connection_id, is_table); @@ -285,11 +326,17 @@ pub fn create(workspace_id: &str, name: &str, source_type: &str, config: &str, f "config": config_value, }); - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let is_table = format == "table"; let spinner = is_table.then(|| crate::util::spinner("Creating connection...")); - let (status, resp_body) = api.post_raw("/connections", &body); + let (status, resp_body) = api.post_raw("/connections", &body).unwrap_or_else(|e| { + if let Some(s) = &spinner { + s.finish_and_clear(); + } + eprintln!("{}", error_text(e)); + std::process::exit(1); + }); if let Some(s) = &spinner { s.finish_and_clear(); } @@ -364,8 +411,19 @@ pub fn create(workspace_id: &str, name: &str, source_type: &str, config: &str, f } pub fn list(workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let body: ListResponse = api.get("/connections"); + let api = Api::new(Some(workspace_id)); + let resp = block(api.client().connections().list()).unwrap_or_else(|e| e.exit()); + let body = ListResponse { + connections: resp + .connections + .into_iter() + .map(|c| Connection { + id: c.id, + name: c.name, + source_type: c.source_type, + }) + .collect(), + }; match format { "json" => { @@ -452,8 +510,11 @@ pub fn refresh( body["include_uncached"] = serde_json::Value::Bool(true); } - let api = ApiClient::new(Some(workspace_id)); - let (status, resp_body) = api.post_raw("/refresh", &body); + let api = Api::new(Some(workspace_id)); + let (status, resp_body) = api.post_raw("/refresh", &body).unwrap_or_else(|e| { + eprintln!("{}", error_text(e).red()); + std::process::exit(1); + }); if !status.is_success() { eprintln!("{}", crate::util::api_error(resp_body).red()); @@ -503,12 +564,12 @@ pub fn refresh( ) .dark_grey() ); - if let Some(errors) = parsed["errors"].as_array() { - if !errors.is_empty() { - eprintln!("{}", format!(" {} error(s):", errors.len()).yellow()); - for err in errors { - eprintln!(" {}", err); - } + if let Some(errors) = parsed["errors"].as_array() + && !errors.is_empty() + { + eprintln!("{}", format!(" {} error(s):", errors.len()).yellow()); + for err in errors { + eprintln!(" {}", err); } } } diff --git a/src/connections_new.rs b/src/connections_new.rs index 9f374a9..985545c 100644 --- a/src/connections_new.rs +++ b/src/connections_new.rs @@ -2,9 +2,9 @@ use inquire::validator::Validation; use inquire::{Confirm, Password, Select, Text}; use serde_json::{Map, Number, Value}; -use crate::api::ApiClient; +use crate::sdk::{block, Api, ApiError}; -// ── HTTP helpers ────────────────────────────────────────────────────────────── +// ── SDK helpers ───────────────────────────────────────────────────────────── struct ConnectionTypeSummary { name: String, @@ -16,34 +16,27 @@ struct ConnectionTypeDetail { auth: Option, } -fn fetch_types(api: &ApiClient) -> Vec { - let body: Value = api.get("/connection-types"); - body["connection_types"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|v| { - Some(ConnectionTypeSummary { - name: v["name"].as_str()?.to_string(), - label: v["label"].as_str()?.to_string(), - }) +fn fetch_types(api: &Api) -> Vec { + let resp = block(api.client().connection_types().list()).unwrap_or_else(|e| e.exit()); + resp.connection_types + .into_iter() + .map(|t| ConnectionTypeSummary { + name: t.name, + label: t.label, }) .collect() } -fn fetch_detail(api: &ApiClient, name: &str) -> ConnectionTypeDetail { - let body: Value = api.get(&format!("/connection-types/{name}")); +fn fetch_detail(api: &Api, name: &str) -> ConnectionTypeDetail { + let detail = block(api.client().connection_types().get(name)).unwrap_or_else(|e| e.exit()); + // The SDK models nullable fields as `Option>`; flatten and + // treat an explicit JSON `null` as absent to match the old `is_null()` check. + let flatten = |field: Option>| -> Option { + field.flatten().filter(|v| !v.is_null()) + }; ConnectionTypeDetail { - config_schema: if body["config_schema"].is_null() { - None - } else { - Some(body["config_schema"].clone()) - }, - auth: if body["auth"].is_null() { - None - } else { - Some(body["auth"].clone()) - }, + config_schema: flatten(detail.config_schema), + auth: flatten(detail.auth), } } @@ -269,7 +262,7 @@ pub fn run(workspace_id: &str) { std::process::exit(1); } - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); // Phase 1: Select connection type let types = fetch_types(&api); @@ -312,65 +305,47 @@ pub fn run(workspace_id: &str) { } // Phase 6: Submit - let body = serde_json::json!({ - "name": conn_name, - "source_type": source_type, - "config": Value::Object(config), - }); - - #[derive(serde::Deserialize)] - struct CreateResponse { - id: String, - name: String, - source_type: String, - tables_discovered: u64, - discovery_status: String, - discovery_error: Option, - } - - #[derive(serde::Deserialize)] - struct HealthResponse { - healthy: bool, - #[serde(default)] - latency_ms: Option, - #[serde(default)] - error: Option, - } + let request = hotdata::models::CreateConnectionRequest::new( + config.into_iter().collect(), + conn_name, + source_type.clone(), + ); + /// Health outcome: a fetched response, or an unavailable reason string. enum HealthStatus { - Available(HealthResponse), + Available(hotdata::models::ConnectionHealthResponse), Unavailable(String), } + /// Render an [`ApiError`] the way the old raw paths did: the server body + /// through `api_error`, or the transport message verbatim. + fn error_text(e: ApiError) -> String { + match e { + ApiError::Status { body, .. } => crate::util::api_error(body), + ApiError::Transport(msg) => msg, + } + } + let create_spinner = crate::util::spinner("Creating connection..."); - let (status_code, resp_body) = api.post_raw("/connections", &body); + let result = block(api.client().connections().create(request)); create_spinner.finish_and_clear(); use crossterm::style::Stylize; - if !status_code.is_success() { - eprintln!("{}", crate::util::api_error(resp_body).red()); - std::process::exit(1); - } - - let result: CreateResponse = match serde_json::from_str(&resp_body) { + let result = match result { Ok(v) => v, Err(e) => { - eprintln!("error parsing response: {e}"); + eprintln!("{}", error_text(e).red()); std::process::exit(1); } }; let health_spinner = crate::util::spinner("Checking connection health..."); - let (hstatus, hbody) = api.get_raw(&format!("/connections/{}/health", result.id)); + let health_result = block(api.client().connections().check_health(&result.id)); health_spinner.finish_and_clear(); - let health = if !hstatus.is_success() { - HealthStatus::Unavailable(crate::util::api_error(hbody)) - } else { - match serde_json::from_str::(&hbody) { - Ok(h) => HealthStatus::Available(h), - Err(e) => HealthStatus::Unavailable(format!("parse error: {e}")), - } + let health = match health_result { + Ok(h) => HealthStatus::Available(h), + Err(e) => HealthStatus::Unavailable(error_text(e)), }; println!("{}", "Connection created".green()); @@ -378,24 +353,26 @@ pub fn run(workspace_id: &str) { println!("name: {}", result.name); println!("source_type: {}", result.source_type); println!("tables_discovered: {}", result.tables_discovered); - let status = match result.discovery_status.as_str() { - "success" => result.discovery_status.green().to_string(), + let discovery_status = result.discovery_status.to_string(); + let status = match discovery_status.as_str() { + "success" => discovery_status.green().to_string(), "failed" => result .discovery_error + .flatten() .as_deref() .unwrap_or("failed") .red() .to_string(), - _ => result.discovery_status.yellow().to_string(), + _ => discovery_status.yellow().to_string(), }; println!("discovery_status: {status}"); let health_str = match &health { - HealthStatus::Available(h) if h.healthy => match h.latency_ms { - Some(ms) => format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()), - None => "healthy".green().to_string(), - }, + HealthStatus::Available(h) if h.healthy => { + let ms = h.latency_ms; + format!("{} {}", "healthy".green(), format!("({ms}ms)").dark_grey()) + } HealthStatus::Available(h) => { - let err = h.error.as_deref().unwrap_or("unknown error"); + let err = h.error.as_ref().and_then(|e| e.as_deref()).unwrap_or("unknown error"); format!("{} — {}", "unhealthy".red(), err) } HealthStatus::Unavailable(err) => { diff --git a/src/context.rs b/src/context.rs index f59d189..5afc055 100644 --- a/src/context.rs +++ b/src/context.rs @@ -1,9 +1,8 @@ //! Database context: `/v1/databases/{id}/context` sync with `./{NAME}.md` in the current directory. -use crate::api::ApiClient; +use crate::sdk::{Api, ApiError}; use crossterm::style::Stylize; -use serde::{Deserialize, Serialize}; -use serde_json::json; +use hotdata::models::{DatabaseContextEntry, UpsertDatabaseContextRequest}; use std::collections::HashSet; use std::fs; use std::io::Write; @@ -27,26 +26,23 @@ static RESERVED_WORDS: LazyLock> = LazyLock::new(|| { .collect() }); -#[derive(Debug, Deserialize, Serialize)] -struct DatabaseContextEntry { +/// Output shape for `list`, preserving the CLI's `name, content, updated_at` +/// field order (the generated SDK model orders them `content, name, updated_at`). +#[derive(serde::Serialize)] +struct ContextRow { name: String, content: String, updated_at: String, } -#[derive(Deserialize)] -struct ListResponse { - contexts: Vec, -} - -#[derive(Deserialize)] -struct GetResponse { - context: DatabaseContextEntry, -} - -#[derive(Deserialize)] -struct UpsertResponse { - context: DatabaseContextEntry, +impl From for ContextRow { + fn from(e: DatabaseContextEntry) -> Self { + ContextRow { + name: e.name, + content: e.content, + updated_at: e.updated_at, + } + } } /// Normalizes a context name from the CLI: trims, takes the final path segment, and strips a @@ -122,33 +118,23 @@ fn local_md_path(name: &str) -> PathBuf { .join(format!("{name}.md")) } -fn fetch_context( - api: &ApiClient, - database_id: &str, - name: &str, -) -> Result { - let path = format!("/databases/{database_id}/context/{name}"); - let (status, body) = api.get_raw(&path); - if status == reqwest::StatusCode::NOT_FOUND { - return Err(status); - } - if !status.is_success() { - eprintln!("{}", format!("error: HTTP {status}").red()); - eprintln!("{body}"); - std::process::exit(1); +/// Fetch a named context document. Returns `Ok(None)` on 404 (not found); +/// exits the process on any other error, matching the old behavior. +fn fetch_context(api: &Api, database_id: &str, name: &str) -> Option { + let result = crate::sdk::block(api.client().database_context().get(database_id, name)); + match crate::sdk::none_if_404(result) { + Ok(Some(resp)) => Some(*resp.context), + Ok(None) => None, + Err(e) => e.exit(), } - let parsed: GetResponse = serde_json::from_str(&body).unwrap_or_else(|e| { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - }); - Ok(parsed.context) } pub fn list(workspace_id: &str, database_id: &str, prefix: Option<&str>, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let body: ListResponse = api.get(&format!("/databases/{database_id}/context")); + let api = Api::new(Some(workspace_id)); + let body = crate::sdk::block(api.client().database_context().list(database_id)) + .unwrap_or_else(|e| e.exit()); - let mut rows: Vec = body.contexts; + let mut rows: Vec = body.contexts.into_iter().map(ContextRow::from).collect(); if let Some(p) = prefix { rows.retain(|c| c.name.starts_with(p)); } @@ -184,15 +170,15 @@ pub fn show(workspace_id: &str, database_id: &str, name: &str) { std::process::exit(1); } - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); match fetch_context(&api, database_id, &name) { - Ok(ctx) => { + Some(ctx) => { print!("{}", ctx.content); if !ctx.content.ends_with('\n') { println!(); } } - Err(reqwest::StatusCode::NOT_FOUND) => { + None => { eprintln!( "{}", format!("error: no context named '{name}' in this database.").red() @@ -204,7 +190,6 @@ pub fn show(workspace_id: &str, database_id: &str, name: &str) { ); std::process::exit(1); } - Err(status) => panic!("unexpected error status from fetch_context: {status}"), } } @@ -229,17 +214,16 @@ pub fn pull(workspace_id: &str, database_id: &str, name: &str, force: bool, dry_ std::process::exit(1); } - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let ctx = match fetch_context(&api, database_id, &name) { - Ok(c) => c, - Err(reqwest::StatusCode::NOT_FOUND) => { + Some(c) => c, + None => { eprintln!( "{}", format!("error: no context named '{name}' in this database.").red() ); std::process::exit(1); } - Err(status) => panic!("unexpected error status from fetch_context: {status}"), }; let n_chars = ctx.content.chars().count(); @@ -309,30 +293,29 @@ pub fn push(workspace_id: &str, database_id: &str, name: &str, dry_run: bool) { return; } - let api = ApiClient::new(Some(workspace_id)); - let body = json!({ "name": &name, "content": content }); - let (status, resp_body) = api.post_raw(&format!("/databases/{database_id}/context"), &body); - - if !status.is_success() { - let msg = crate::util::api_error(resp_body.clone()); - if msg.to_lowercase().contains("not allowed within a session") { - eprintln!("{}", msg.red()); - eprintln!( - "{}", - "hint: context push is blocked inside an active sandbox. \ + let api = Api::new(Some(workspace_id)); + let request = UpsertDatabaseContextRequest::new(content, name.clone()); + let resp = match crate::sdk::block( + api.client().database_context().upsert(database_id, request), + ) { + Ok(resp) => resp, + Err(ApiError::Status { status: _, body }) => { + let msg = crate::util::api_error(body); + if msg.to_lowercase().contains("not allowed within a session") { + eprintln!("{}", msg.red()); + eprintln!( + "{}", + "hint: context push is blocked inside an active sandbox. \ Run 'hotdata sandbox set' (no args) to clear the active sandbox first." - .dark_grey() - ); - } else { - eprintln!("{}", msg.red()); + .dark_grey() + ); + } else { + eprintln!("{}", msg.red()); + } + std::process::exit(1); } - std::process::exit(1); - } - - let resp: UpsertResponse = serde_json::from_str(&resp_body).unwrap_or_else(|e| { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - }); + Err(e @ ApiError::Transport(_)) => e.exit(), + }; println!( "{}", diff --git a/src/database_session.rs b/src/database_session.rs index 732e1c8..aba7ab4 100644 --- a/src/database_session.rs +++ b/src/database_session.rs @@ -184,7 +184,7 @@ pub fn database_token_in_use() -> Option<(String, Option)> { } /// In-child equivalent of a parent-side `ensure_access_token`: operates -/// on env vars only. Used by [`crate::api::ApiClient`] when the parent +/// on env vars only. Used by [`crate::sdk::Api`] when the parent /// `databases run` already passed in `HOTDATA_DATABASE_TOKEN` and /// `HOTDATA_DATABASE_REFRESH_TOKEN`. The new tokens are *not* persisted /// to disk — the child may not have write access to the parent's diff --git a/src/databases.rs b/src/databases.rs index 18e8c39..95430bf 100644 --- a/src/databases.rs +++ b/src/databases.rs @@ -1,4 +1,4 @@ -use crate::api::ApiClient; +use crate::sdk::{none_if_404, Api}; use indicatif::{ProgressBar, ProgressStyle}; use serde::{Deserialize, Serialize}; use std::path::Path; @@ -51,13 +51,6 @@ struct InfoTable { last_sync: Option, } -#[derive(Deserialize)] -struct InfoListResponse { - tables: Vec, - has_more: bool, - next_cursor: Option, -} - #[derive(Deserialize, Serialize)] struct TableRow { full_name: String, @@ -100,21 +93,25 @@ struct LoadManagedTableResponse { arrow_schema_json: String, } -fn fetch_database(api: &ApiClient, id: &str) -> Database { - api.get(&format!("/databases/{id}")) +fn fetch_database(api: &Api, id: &str) -> Database { + api.get_json(&format!("/databases/{id}"), &[]) + .unwrap_or_else(|e| e.exit()) } -pub fn try_resolve_database(api: &ApiClient, id_or_name: &str) -> Result { +pub fn try_resolve_database(api: &Api, id_or_name: &str) -> Result { // Try a direct id lookup first — avoids the list round-trip for the common case. // Percent-encode the segment so names containing spaces or other URL-unsafe // characters don't cause a URL parse error before the list fallback can run. let encoded = urlencoding::encode(id_or_name); - if let Some(db) = api.get_none_if_not_found(&format!("/databases/{encoded}")) { + if let Some(db) = + none_if_404(api.get_json::(&format!("/databases/{encoded}"), &[])) + .unwrap_or_else(|e| e.exit()) + { return Ok(db); } // Fall back to listing — prefer catalog alias match, then name. - let body: ListDatabasesResponse = api.get("/databases"); + let body: ListDatabasesResponse = api.get_json("/databases", &[]).unwrap_or_else(|e| e.exit()); let catalog_matches: Vec<&DatabaseSummary> = body .databases @@ -151,7 +148,7 @@ pub fn try_resolve_database(api: &ApiClient, id_or_name: &str) -> Result Database { +pub fn resolve_database(api: &Api, id_or_name: &str) -> Database { match try_resolve_database(api, id_or_name) { Ok(db) => db, Err(e) => { @@ -263,12 +260,43 @@ fn table_rows(catalog: &str, tables: Vec) -> Vec { } fn finish_upload( - api: &ApiClient, + api: &Api, reader: impl std::io::Read + Send + 'static, size: Option, pb: &ProgressBar, ) -> String { - let (status, resp_body) = api.post_body("/files", "application/octet-stream", reader, size); + // The streaming `/files` upload stays on the slim raw-HTTP helper: it + // needs no request timeout (a 10 GB+ parquet far outlives the seam's + // 300s default), is one-shot (no 401-retry — the body is consumed on the + // first send), and the SDK's `uploads().upload` is `PathBuf`-only with no + // progress hook or `--url` source. We still carry the same + // `Authorization: Bearer ` (resolved through the seam's installed + // token provider) and `X-Workspace-Id` header every other call uses. + let upload_client = crate::raw_http::build_upload_client(); + let url = format!("{}/files", api.api_url); + let mut req = upload_client + .post(&url) + .header("Content-Type", "application/octet-stream"); + if let Some(bearer) = api.current_bearer() { + req = req.header("Authorization", format!("Bearer {bearer}")); + } + if let Some(ws) = api.workspace_id() { + req = req.header("X-Workspace-Id", ws); + } + if let Some(len) = size { + req = req.header("Content-Length", len); + } + let req = req.body(reqwest::blocking::Body::new(reader)); + + // Body is an opaque stream, so pass `None` for logging; headers + // (including the masked Authorization) still log. + let (status, resp_body) = match crate::util::send_debug(&upload_client, req, None) { + Ok(pair) => pair, + Err(e) => { + eprintln!("error connecting to API: {e}"); + std::process::exit(1); + } + }; pb.finish_and_clear(); if !status.is_success() { @@ -293,7 +321,7 @@ fn finish_upload( } } -fn upload_parquet_file(api: &ApiClient, path: &str) -> String { +fn upload_parquet_file(api: &Api, path: &str) -> String { if !is_parquet_path(path) { eprintln!( "error: managed table loads require a parquet file (got '{}'). \ @@ -324,7 +352,7 @@ fn upload_parquet_file(api: &ApiClient, path: &str) -> String { finish_upload(api, reader, Some(file_size), &pb) } -fn upload_parquet_url(api: &ApiClient, url: &str) -> String { +fn upload_parquet_url(api: &Api, url: &str) -> String { if !is_parquet_path(url) { eprintln!( "error: managed table loads require a parquet URL ending in .parquet (got '{url}')." @@ -374,24 +402,30 @@ fn upload_parquet_url(api: &ApiClient, url: &str) -> String { finish_upload(api, reader, content_length, &pb) } -fn collect_tables(api: &ApiClient, connection_id: &str, schema: Option<&str>) -> Vec { +fn collect_tables(api: &Api, connection_id: &str, schema: Option<&str>) -> Vec { let mut out = Vec::new(); let mut cursor: Option = None; loop { - let mut params: Vec<(&str, Option)> = - vec![("connection_id", Some(connection_id.to_string()))]; - if let Some(s) = schema { - params.push(("schema", Some(s.to_string()))); - } - if let Some(ref c) = cursor { - params.push(("cursor", Some(c.clone()))); - } - let body: InfoListResponse = api.get_with_params("/information_schema", ¶ms); - out.extend(body.tables); - if !body.has_more { + let resp = crate::sdk::block(api.client().information_schema().get( + Some(connection_id), + schema, + None, + None, + None, + cursor.as_deref(), + )) + .unwrap_or_else(|e| e.exit()); + out.extend(resp.tables.into_iter().map(|t| InfoTable { + connection: t.connection, + schema: t.schema, + table: t.table, + synced: t.synced, + last_sync: t.last_sync.flatten(), + })); + if !resp.has_more { break; } - let Some(c) = body.next_cursor else { + let Some(c) = resp.next_cursor.flatten() else { break; }; cursor = Some(c); @@ -405,8 +439,9 @@ fn collect_tables(api: &ApiClient, connection_id: &str, schema: Option<&str>) -> } pub fn list(workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let body: ListDatabasesResponse = api.get("/databases"); + let api = Api::new(Some(workspace_id)); + let body: ListDatabasesResponse = + api.get_json("/databases", &[]).unwrap_or_else(|e| e.exit()); match format { "json" => println!("{}", serde_json::to_string_pretty(&body.databases).unwrap()), @@ -441,7 +476,7 @@ pub fn list(workspace_id: &str, format: &str) { } pub fn get(workspace_id: &str, id_or_name: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let db = resolve_database(&api, id_or_name); match format { @@ -494,7 +529,7 @@ pub fn get(workspace_id: &str, id_or_name: &str, format: &str) { /// `--database` is given. Mirrors `create`'s request path but returns /// the id instead of printing. fn create_and_return_id( - api: &ApiClient, + api: &Api, name: Option<&str>, schema: &str, tables: &[String], @@ -502,7 +537,7 @@ fn create_and_return_id( ) -> String { use crossterm::style::Stylize; let body = create_database_request(name, None, schema, tables, expires_at); - let (status, resp_body) = api.post_raw("/databases", &body); + let (status, resp_body) = api.post_raw("/databases", &body).unwrap_or_else(|e| e.exit()); if !status.is_success() { eprintln!("{}", crate::util::api_error(resp_body).red()); std::process::exit(1); @@ -521,12 +556,30 @@ fn create_and_return_id( /// `POST /v1/auth/database` (grant_type=existing_database). The call /// doubles as an existence + access check (the server 404s an unknown /// or unreachable database). -fn mint_database_token(api: &ApiClient, database_id: &str) -> DatabaseTokenResponse { +fn mint_database_token(api: &Api, database_id: &str) -> DatabaseTokenResponse { let body = serde_json::json!({ "grant_type": "existing_database", "database_id": database_id, }); - api.post("/auth/database", &body) + let (status, resp_body) = + api.post_raw("/auth/database", &body).unwrap_or_else(|e| e.exit()); + if !status.is_success() { + // The old typed `api.post` routed non-success through `fail_response`, + // which upgrades a masked 401/403/404 into the re-auth hint. Reproduce + // that via the seam's auth-aware exit. + crate::sdk::ApiError::Status { + status, + body: resp_body, + } + .exit(); + } + match serde_json::from_str(&resp_body) { + Ok(v) => v, + Err(e) => { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + } + } } /// Run a command with a database-scoped token. Creates a new database @@ -544,7 +597,7 @@ pub fn run( use crossterm::style::Stylize; use std::time::{SystemTime, UNIX_EPOCH}; - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); // Unlike `create`, we don't persist the auto-created database as the // workspace's "current" database: a `run` database is scratch/ephemeral @@ -606,9 +659,14 @@ pub fn create( let body = create_database_request(name, catalog, schema, tables, expires_at); - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let spinner = (format == "table").then(|| crate::util::spinner("Creating database...")); - let (status, resp_body) = api.post_raw("/databases", &body); + let (status, resp_body) = api.post_raw("/databases", &body).unwrap_or_else(|e| { + if let Some(s) = &spinner { + s.finish_and_clear(); + } + e.exit() + }); if let Some(s) = &spinner { s.finish_and_clear(); } @@ -681,9 +739,12 @@ pub fn unset(workspace_id: &str) { pub fn set(workspace_id: &str, id: &str) { use crossterm::style::Stylize; - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let encoded = urlencoding::encode(id); - if api.get_none_if_not_found::(&format!("/databases/{encoded}")).is_none() { + if none_if_404(api.get_json::(&format!("/databases/{encoded}"), &[])) + .unwrap_or_else(|e| e.exit()) + .is_none() + { eprintln!("{}", format!("error: no database with id '{id}'").red()); std::process::exit(1); } @@ -714,9 +775,11 @@ fn resolve_current_database(provided: Option<&str>, workspace_id: &str) -> Strin pub fn delete(workspace_id: &str, id_or_name: &str) { use crossterm::style::Stylize; - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let db = resolve_database(&api, id_or_name); - let (status, resp_body) = api.delete_raw(&format!("/databases/{}", db.id)); + let (status, resp_body) = api + .delete_raw(&format!("/databases/{}", db.id)) + .unwrap_or_else(|e| e.exit()); if !status.is_success() { eprintln!("{}", crate::util::api_error(resp_body).red()); @@ -734,7 +797,7 @@ pub fn delete(workspace_id: &str, id_or_name: &str) { pub fn tables_list(workspace_id: &str, database: Option<&str>, schema: Option<&str>, format: &str) { let database = resolve_current_database(database, workspace_id); - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let db = resolve_database(&api, &database); let catalog = db.default_catalog.as_deref().or(db.name.as_deref()).unwrap_or("default"); let tables = collect_tables(&api, &db.default_connection_id, schema); @@ -781,13 +844,16 @@ pub fn tables_load( use crossterm::style::Stylize; let database = resolve_current_database(database, workspace_id); - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); // Prefer the active database when its catalog or name matches the lookup key, // avoiding ambiguity when multiple databases share the same catalog name. let active_id = crate::config::load_current_database("default", workspace_id); let lookup_key = match active_id.as_deref() { Some(id) => { - if let Some(active) = api.get_none_if_not_found::(&format!("/databases/{id}")) { + if let Some(active) = + none_if_404(api.get_json::(&format!("/databases/{id}"), &[])) + .unwrap_or_else(|e| e.exit()) + { if active.default_catalog.as_deref() == Some(database.as_str()) || active.name.as_deref() == Some(database.as_str()) { @@ -820,7 +886,10 @@ pub fn tables_load( let body = load_table_request(&upload_id); let spinner = crate::util::spinner("Loading table..."); - let (status, resp_body) = api.post_raw(&path, &body); + let (status, resp_body) = api.post_raw(&path, &body).unwrap_or_else(|e| { + spinner.finish_and_clear(); + e.exit() + }); spinner.finish_and_clear(); let (status, resp_body) = if !status.is_success() @@ -879,7 +948,9 @@ pub fn tables_load( } } - let (del_status, del_body) = api.delete_raw(&format!("/databases/{}", db.id)); + let (del_status, del_body) = api + .delete_raw(&format!("/databases/{}", db.id)) + .unwrap_or_else(|e| e.exit()); if !del_status.is_success() { eprintln!("{}", crate::util::api_error(del_body).red()); std::process::exit(1); @@ -891,7 +962,8 @@ pub fn tables_load( &all_tables, db.expires_at.as_deref(), ); - let (create_status, create_body_resp) = api.post_raw("/databases", &create_body); + let (create_status, create_body_resp) = + api.post_raw("/databases", &create_body).unwrap_or_else(|e| e.exit()); if !create_status.is_success() { eprintln!("{}", crate::util::api_error(create_body_resp).red()); std::process::exit(1); @@ -906,7 +978,10 @@ pub fn tables_load( let _ = crate::config::save_current_database("default", workspace_id, &new_db.id); let new_path = managed_table_load_path(&new_db.default_connection_id, schema, table); let spinner = crate::util::spinner("Loading table..."); - let result = api.post_raw(&new_path, &body); + let result = api.post_raw(&new_path, &body).unwrap_or_else(|e| { + spinner.finish_and_clear(); + e.exit() + }); spinner.finish_and_clear(); result } else { @@ -951,12 +1026,12 @@ pub fn tables_delete(workspace_id: &str, database: Option<&str>, table: &str, sc use crossterm::style::Stylize; let database = resolve_current_database(database, workspace_id); - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let db = resolve_database(&api, &database); let schema = schema_name(schema); let path = managed_table_delete_path(&db.default_connection_id, schema, table); - let (status, resp_body) = api.delete_raw(&path); + let (status, resp_body) = api.delete_raw(&path).unwrap_or_else(|e| e.exit()); if !status.is_success() { eprintln!("{}", crate::util::api_error(resp_body).red()); @@ -1059,30 +1134,30 @@ mod tests { let mut server = mockito::Server::new(); // by-id path: direct GET /databases/db_abc succeeds let by_id_mock = server - .mock("GET", "/databases/db_abc") + .mock("GET", "/v1/databases/db_abc") .with_status(200) .with_body(full_detail("db_abc", "sales", "conn_1")) .create(); // by-name path: GET /databases/warehouse → 404, then list, then detail let not_id = server - .mock("GET", "/databases/warehouse") + .mock("GET", "/v1/databases/warehouse") .with_status(404) .with_body(r#"{"error":"not found"}"#) .create(); let list = server - .mock("GET", "/databases") + .mock("GET", "/v1/databases") .with_status(200) .with_body( r#"{"databases":[{"id":"db_abc","name":"sales"},{"id":"db_xyz","name":"warehouse"}]}"#, ) .create(); let detail = server - .mock("GET", "/databases/db_xyz") + .mock("GET", "/v1/databases/db_xyz") .with_status(200) .with_body(full_detail("db_xyz", "warehouse", "conn_2")) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let api = Api::test_new(&server.url(), "k", Some("ws")); let by_id = resolve_database(&api, "db_abc"); assert_eq!(by_id.default_connection_id, "conn_1"); let by_name = resolve_database(&api, "warehouse"); @@ -1098,18 +1173,18 @@ mod tests { let mut server = mockito::Server::new(); // Direct id lookup returns 404 server - .mock("GET", "/databases/missing") + .mock("GET", "/v1/databases/missing") .with_status(404) .with_body(r#"{"error":"not found"}"#) .create(); // List also returns nothing server - .mock("GET", "/databases") + .mock("GET", "/v1/databases") .with_status(200) .with_body(r#"{"databases":[]}"#) .create(); - let api = ApiClient::test_new(&server.url(), "k", None); + let api = Api::test_new(&server.url(), "k", None); let err = try_resolve_database(&api, "missing").unwrap_err(); assert!(err.contains("no database with id")); } @@ -1119,20 +1194,20 @@ mod tests { let mut server = mockito::Server::new(); // Direct id lookup returns 404 (name isn't a valid id) server - .mock("GET", "/databases/sales") + .mock("GET", "/v1/databases/sales") .with_status(404) .with_body(r#"{"error":"not found"}"#) .create(); // List returns two entries with the same name server - .mock("GET", "/databases") + .mock("GET", "/v1/databases") .with_status(200) .with_body( r#"{"databases":[{"id":"db_1","name":"sales"},{"id":"db_2","name":"sales"}]}"#, ) .create(); - let api = ApiClient::test_new(&server.url(), "k", None); + let api = Api::test_new(&server.url(), "k", None); let err = try_resolve_database(&api, "sales").unwrap_err(); assert!(err.contains("multiple databases")); } @@ -1183,29 +1258,31 @@ mod tests { fn collect_tables_follows_cursor() { let mut server = mockito::Server::new(); let page1 = server - .mock("GET", "/information_schema") + .mock("GET", "/v1/information_schema") .match_query(mockito::Matcher::AllOf(vec![ mockito::Matcher::UrlEncoded("connection_id".into(), "conn1".into()), mockito::Matcher::UrlEncoded("cursor".into(), "cur2".into()), ])) .with_status(200) + .with_header("content-type", "application/json") .with_body( - r#"{"tables":[{"connection":"default","schema":"public","table":"b","synced":true,"last_sync":null}],"has_more":false,"next_cursor":null}"#, + r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"b","synced":true,"last_sync":null}],"has_more":false,"next_cursor":null}"#, ) .create(); let page0 = server - .mock("GET", "/information_schema") + .mock("GET", "/v1/information_schema") .match_query(mockito::Matcher::UrlEncoded( "connection_id".into(), "conn1".into(), )) .with_status(200) + .with_header("content-type", "application/json") .with_body( - r#"{"tables":[{"connection":"default","schema":"public","table":"a","synced":false,"last_sync":null}],"has_more":true,"next_cursor":"cur2"}"#, + r#"{"count":1,"limit":1000,"tables":[{"connection":"default","schema":"public","table":"a","synced":false,"last_sync":null}],"has_more":true,"next_cursor":"cur2"}"#, ) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let api = Api::test_new(&server.url(), "k", Some("ws")); let tables = collect_tables(&api, "conn1", None); page0.assert(); page1.assert(); @@ -1218,7 +1295,7 @@ mod tests { fn create_posts_to_databases_endpoint() { let mut server = mockito::Server::new(); let mock = server - .mock("POST", "/databases") + .mock("POST", "/v1/databases") .match_header("X-Workspace-Id", "ws-test") .with_status(201) .with_body( @@ -1236,9 +1313,9 @@ mod tests { )) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws-test")); + let api = Api::test_new(&server.url(), "k", Some("ws-test")); let body = create_database_request(Some("mydb"), None, "public", &["gdp".to_string()], None); - let (status, resp_body) = api.post_raw("/databases", &body); + let (status, resp_body) = api.post_raw("/databases", &body).unwrap(); assert_eq!(status.as_u16(), 201); let parsed: CreateDatabaseResponse = serde_json::from_str(&resp_body).unwrap(); assert_eq!(parsed.name.as_deref(), Some("mydb")); @@ -1251,14 +1328,14 @@ mod tests { let mut server = mockito::Server::new(); // resolve_database resolves by id directly let resolve = server - .mock("GET", "/databases/db_1") + .mock("GET", "/v1/databases/db_1") .with_status(200) .with_body(full_detail("db_1", "sales", "conn_default")) .create(); let load = server .mock( "POST", - "/connections/conn_default/schemas/public/tables/orders/loads", + "/v1/connections/conn_default/schemas/public/tables/orders/loads", ) .match_body(mockito::Matcher::JsonString( serde_json::to_string(&load_table_request("upl_123")).unwrap(), @@ -1275,11 +1352,11 @@ mod tests { ) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws1")); + let api = Api::test_new(&server.url(), "k", Some("ws1")); let db = resolve_database(&api, "db_1"); let path = managed_table_load_path(&db.default_connection_id, "public", "orders"); let body = load_table_request("upl_123"); - let (status, resp_body) = api.post_raw(&path, &body); + let (status, resp_body) = api.post_raw(&path, &body).unwrap(); assert!(status.is_success()); let parsed: LoadManagedTableResponse = serde_json::from_str(&resp_body).unwrap(); assert_eq!(parsed.row_count, 42); @@ -1292,23 +1369,23 @@ mod tests { fn tables_delete_uses_default_connection_id() { let mut server = mockito::Server::new(); let resolve = server - .mock("GET", "/databases/db_1") + .mock("GET", "/v1/databases/db_1") .with_status(200) .with_body(full_detail("db_1", "sales", "conn_default")) .create(); let delete = server .mock( "DELETE", - "/connections/conn_default/schemas/public/tables/orders", + "/v1/connections/conn_default/schemas/public/tables/orders", ) .with_status(204) .with_body("") .create(); - let api = ApiClient::test_new(&server.url(), "k", None); + let api = Api::test_new(&server.url(), "k", None); let db = resolve_database(&api, "db_1"); let path = managed_table_delete_path(&db.default_connection_id, "public", "orders"); - let (status, _) = api.delete_raw(&path); + let (status, _) = api.delete_raw(&path).unwrap(); assert_eq!(status.as_u16(), 204); resolve.assert(); delete.assert(); @@ -1343,7 +1420,7 @@ mod tests { fn create_and_return_id_parses_id() { let mut server = mockito::Server::new(); let m = server - .mock("POST", "/databases") + .mock("POST", "/v1/databases") .match_body(mockito::Matcher::Json(create_database_request( Some("scratch"), None, @@ -1355,7 +1432,7 @@ mod tests { .with_header("content-type", "application/json") .with_body(r#"{"id":"dbid_new","description":"scratch","default_connection_id":"conn_1"}"#) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let api = Api::test_new(&server.url(), "k", Some("ws")); let id = create_and_return_id(&api, Some("scratch"), "public", &[], None); m.assert(); assert_eq!(id, "dbid_new"); @@ -1365,7 +1442,7 @@ mod tests { fn mint_database_token_posts_existing_database_grant() { let mut server = mockito::Server::new(); let m = server - .mock("POST", "/auth/database") + .mock("POST", "/v1/auth/database") .match_body(mockito::Matcher::JsonString( r#"{"grant_type":"existing_database","database_id":"dbid_abc"}"#.to_string(), )) @@ -1373,7 +1450,7 @@ mod tests { .with_header("content-type", "application/json") .with_body(r#"{"ok":true,"token":"jwt-x","refresh_token":"rt-x","database_id":"dbid_abc","expires_in":300,"refresh_expires_in":259200}"#) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let api = Api::test_new(&server.url(), "k", Some("ws")); let resp = mint_database_token(&api, "dbid_abc"); m.assert(); assert_eq!(resp.token, "jwt-x"); diff --git a/src/datasets.rs b/src/datasets.rs index 735031e..e858fd6 100644 --- a/src/datasets.rs +++ b/src/datasets.rs @@ -1,46 +1,44 @@ -use crate::api::ApiClient; -use serde::{Deserialize, Serialize}; -use serde_json::json; - -#[derive(Deserialize, Serialize)] -struct Dataset { +use crate::sdk::Api; +use hotdata::models::{ + CreateDatasetRequest, CreateDatasetResponse, DatasetSource, DatasetSourceOneOf1, + DatasetSourceOneOf2, GetDatasetResponse, RefreshRequest, RefreshResponse, UpdateDatasetRequest, + UpdateDatasetResponse, +}; +use serde::Serialize; + +/// Output shape for `create`, preserving the CLI's field order for json/yaml. +#[derive(Serialize)] +struct CreateView { id: String, label: String, - #[serde(default = "default_schema")] schema_name: String, table_name: String, - created_at: String, - updated_at: String, } -fn default_schema() -> String { - "main".to_string() +impl From for CreateView { + fn from(r: CreateDatasetResponse) -> Self { + CreateView { + id: r.id, + label: r.label, + schema_name: r.schema_name, + table_name: r.table_name, + } + } } -#[derive(Deserialize, Serialize)] -struct CreateResponse { +/// Output shape for `list` rows. +#[derive(Serialize)] +struct DatasetView { id: String, label: String, - #[serde(default = "default_schema")] schema_name: String, table_name: String, + created_at: String, + updated_at: String, } -#[derive(Deserialize)] -struct ListResponse { - datasets: Vec, - count: u64, - has_more: bool, -} - -#[derive(Deserialize, Serialize)] -struct Column { - name: String, - data_type: String, - nullable: bool, -} - -#[derive(Deserialize, Serialize)] +/// Output shape for `get`. +#[derive(Serialize)] struct DatasetDetail { id: String, label: String, @@ -52,49 +50,59 @@ struct DatasetDetail { columns: Vec, } -#[derive(Deserialize, Serialize)] -struct UpdateResponse { +#[derive(Serialize)] +struct Column { + name: String, + data_type: String, + nullable: bool, +} + +/// Output shape for `update`, preserving the CLI's field order and optional +/// `schema_name`. runtimedb's `UpdateDatasetResponse` does not currently send +/// `schema_name`, so we don't synthesize one — sandbox-scoped datasets live +/// under `datasets..`, not `datasets.main.*`. +#[derive(Serialize)] +struct UpdateView { id: String, label: String, - // Not currently in runtimedb's UpdateDatasetResponse; kept Optional so we - // print `full_name` only when the server actually returns the schema. - // Synthesizing "main" is wrong for sandbox-scoped datasets where - // schema_name == sandbox_id. - #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] schema_name: Option, table_name: String, - #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] latest_version: Option, - #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] pinned_version: Option, updated_at: String, } -fn create_dataset( - api: &ApiClient, - description: Option<&str>, - name: &str, - source: serde_json::Value, - format: &str, -) { - let label = description.unwrap_or(name); - let body = json!({ "table_name": name, "label": label, "source": source }); - - let (status, resp_body) = api.post_raw("/datasets", &body); - - if !status.is_success() { - use crossterm::style::Stylize; - eprintln!("{}", crate::util::api_error(resp_body).red()); - std::process::exit(1); +impl From for UpdateView { + fn from(r: UpdateDatasetResponse) -> Self { + UpdateView { + id: r.id, + label: r.label, + // The SDK model carries no schema_name; keep None so we print the + // unqualified table_name + the "see qualified name" hint. + schema_name: None, + table_name: r.table_name, + latest_version: Some(r.latest_version), + pinned_version: r.pinned_version.flatten(), + updated_at: r.updated_at, + } } +} - let dataset: CreateResponse = match serde_json::from_str(&resp_body) { - Ok(v) => v, - Err(e) => { - eprintln!("error parsing response: {e}"); - std::process::exit(1); - } +fn create_dataset(api: &Api, description: Option<&str>, name: &str, source: DatasetSource, format: &str) { + let label = description.unwrap_or(name).to_string(); + let mut request = CreateDatasetRequest::new(label, source); + request.table_name = Some(Some(name.to_string())); + + let resp = match crate::sdk::block( + api.client().datasets().create(request, api.database_id()), + ) { + Ok(r) => r, + Err(e) => e.exit(), }; + let dataset = CreateView::from(resp); use crossterm::style::Stylize; match format { @@ -114,8 +122,12 @@ fn create_dataset( } pub fn create_from_query(workspace_id: &str, sql: &str, description: Option<&str>, name: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - create_dataset(&api, description, name, json!({ "type": "sql_query", "sql": sql }), format); + let api = Api::new(Some(workspace_id)); + let source = DatasetSource::DatasetSourceOneOf2(Box::new(DatasetSourceOneOf2::new( + sql.to_string(), + hotdata::models::dataset_source_one_of_2::Type::SqlQuery, + ))); + create_dataset(&api, description, name, source, format); } pub fn create_from_saved_query( @@ -125,29 +137,46 @@ pub fn create_from_saved_query( name: &str, format: &str, ) { - let api = ApiClient::new(Some(workspace_id)); - create_dataset(&api, description, name, json!({ "type": "saved_query", "saved_query_id": query_id }), format); + let api = Api::new(Some(workspace_id)); + let source = DatasetSource::DatasetSourceOneOf1(Box::new(DatasetSourceOneOf1::new( + query_id.to_string(), + hotdata::models::dataset_source_one_of_1::Type::SavedQuery, + ))); + create_dataset(&api, description, name, source, format); } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - - let params = [ - ("limit", limit.map(|l| l.to_string())), - ("offset", offset.map(|o| o.to_string())), - ]; - let body: ListResponse = api.get_with_params("/datasets", ¶ms); + let api = Api::new(Some(workspace_id)); + + let body = crate::sdk::block( + api.client() + .datasets() + .list(limit.map(|l| l as i32), offset.map(|o| o as i32)), + ) + .unwrap_or_else(|e| e.exit()); + + let datasets: Vec = body + .datasets + .into_iter() + .map(|d| DatasetView { + id: d.id, + label: d.label, + schema_name: d.schema_name, + table_name: d.table_name, + created_at: d.created_at, + updated_at: d.updated_at, + }) + .collect(); match format { - "json" => println!("{}", serde_json::to_string_pretty(&body.datasets).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(&body.datasets).unwrap()), + "json" => println!("{}", serde_json::to_string_pretty(&datasets).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&datasets).unwrap()), "table" => { - if body.datasets.is_empty() { + if datasets.is_empty() { use crossterm::style::Stylize; eprintln!("{}", "No datasets found.".dark_grey()); } else { - let rows: Vec> = body - .datasets + let rows: Vec> = datasets .iter() .map(|d| { vec![ @@ -161,7 +190,7 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: crate::table::print(&["ID", "LABEL", "FULL NAME", "CREATED AT"], &rows); } if body.has_more { - let next = offset.unwrap_or(0) + body.count as u32; + let next = offset.unwrap_or(0) + body.count.max(0) as u32; use crossterm::style::Stylize; eprintln!( "{}", @@ -178,9 +207,29 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: } pub fn get(dataset_id: &str, workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - - let d: DatasetDetail = api.get(&format!("/datasets/{dataset_id}")); + let api = Api::new(Some(workspace_id)); + + let resp: GetDatasetResponse = + crate::sdk::block(api.client().datasets().get(dataset_id)).unwrap_or_else(|e| e.exit()); + + let d = DatasetDetail { + id: resp.id, + label: resp.label, + schema_name: resp.schema_name, + table_name: resp.table_name, + source_type: resp.source_type, + created_at: resp.created_at, + updated_at: resp.updated_at, + columns: resp + .columns + .into_iter() + .map(|c| Column { + name: c.name, + data_type: c.data_type, + nullable: c.nullable, + }) + .collect(), + }; match format { "json" => println!("{}", serde_json::to_string_pretty(&d).unwrap()), @@ -226,17 +275,20 @@ pub fn update( std::process::exit(1); } - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); - let mut body = json!({}); + let mut request = UpdateDatasetRequest::new(); if let Some(d) = description { - body["label"] = json!(d); + request.label = Some(Some(d.to_string())); } if let Some(n) = name { - body["table_name"] = json!(n); + request.table_name = Some(Some(n.to_string())); } - let d: UpdateResponse = api.put(&format!("/datasets/{dataset_id}"), &body); + let resp: UpdateDatasetResponse = + crate::sdk::block(api.client().datasets().update(dataset_id, request)) + .unwrap_or_else(|e| e.exit()); + let d = UpdateView::from(resp); use crossterm::style::Stylize; eprintln!("{}", "Dataset updated".green()); @@ -271,25 +323,22 @@ pub fn update( pub fn refresh(workspace_id: &str, dataset_id: &str, async_mode: bool) { use crossterm::style::Stylize; - let mut body = json!({ - "dataset_id": dataset_id, - }); - if async_mode { - body["async"] = json!(true); - } - - let api = ApiClient::new(Some(workspace_id)); - let (status, resp_body) = api.post_raw("/refresh", &body); + let api = Api::new(Some(workspace_id)); - if !status.is_success() { - eprintln!("{}", crate::util::api_error(resp_body).red()); - std::process::exit(1); + let mut request = RefreshRequest::new(); + request.dataset_id = Some(Some(dataset_id.to_string())); + if async_mode { + request.r#async = Some(true); } - let parsed: serde_json::Value = serde_json::from_str(&resp_body).unwrap_or_default(); + let resp = crate::sdk::block(api.client().refresh().refresh(request)) + .unwrap_or_else(|e| e.exit()); if async_mode { - let job_id = parsed["id"].as_str().unwrap_or("unknown"); + let job_id = match &resp { + RefreshResponse::SubmitJobResponse(j) => j.id.clone(), + _ => "unknown".to_string(), + }; println!("{}", "Dataset refresh submitted.".green()); println!("job_id: {}", job_id); println!( @@ -299,9 +348,15 @@ pub fn refresh(workspace_id: &str, dataset_id: &str, async_mode: bool) { return; } - let id = parsed["id"].as_str().unwrap_or("unknown"); - let version = parsed["version"].as_i64().unwrap_or(0); - let dataset_status = parsed["status"].as_str().unwrap_or(""); + let (id, version, dataset_status) = match &resp { + RefreshResponse::RefreshDatasetResponse(r) => { + (r.id.clone(), r.version as i64, r.status.clone()) + } + RefreshResponse::SubmitJobResponse(j) => { + (j.id.clone(), 0, j.status.to_string()) + } + _ => ("unknown".to_string(), 0, String::new()), + }; println!("{}", "Dataset refresh completed.".green()); println!( "{}", @@ -312,13 +367,14 @@ pub fn refresh(workspace_id: &str, dataset_id: &str, async_mode: bool) { #[cfg(test)] mod tests { use super::*; + use hotdata::models::UpdateDatasetResponse; /// Mirrors runtimedb's `UpdateDatasetResponse` (see runtimedb/src/http/models.rs). - /// The CLI must deserialize this exact shape — schema_name, source_type, - /// created_at, and columns are NOT in the response. If runtimedb's response - /// gains or loses fields, update this fixture in lockstep. + /// The SDK deserializes this exact shape; here we assert the CLI's `UpdateView` + /// conversion preserves the display contract: no synthesized schema_name, and + /// latest/pinned versions surfaced when present. #[test] - fn update_response_deserializes_runtimedb_payload() { + fn update_view_from_runtimedb_payload() { let body = serde_json::json!({ "id": "ds_abc123", "label": "url_test", @@ -326,56 +382,52 @@ mod tests { "latest_version": 3, "updated_at": "2026-04-28T18:30:00Z", }); - let resp: UpdateResponse = serde_json::from_value(body).unwrap(); - assert_eq!(resp.id, "ds_abc123"); - assert_eq!(resp.label, "url_test"); - assert_eq!(resp.table_name, "url_test"); - // The server doesn't currently send schema_name, so we don't synthesize - // one — sandbox-scoped datasets live under datasets..
, - // not datasets.main.*, and a fabricated "main" would mislead users. - assert!(resp.schema_name.is_none()); - assert_eq!(resp.latest_version, Some(3)); - assert!(resp.pinned_version.is_none()); + let resp: UpdateDatasetResponse = serde_json::from_value(body).unwrap(); + let view = UpdateView::from(resp); + assert_eq!(view.id, "ds_abc123"); + assert_eq!(view.label, "url_test"); + assert_eq!(view.table_name, "url_test"); + // The server doesn't send schema_name and we never synthesize "main", + // so sandbox-scoped datasets aren't mislabeled. + assert!(view.schema_name.is_none()); + assert_eq!(view.latest_version, Some(3)); + assert!(view.pinned_version.is_none()); } #[test] - fn update_response_uses_schema_name_when_server_supplies_it() { - // Forward-compat: if runtimedb later includes schema_name, we use it. + fn update_view_handles_pinned_version() { let body = serde_json::json!({ "id": "ds_abc123", "label": "x", - "schema_name": "sandbox_xyz", "table_name": "x", + "latest_version": 5, + "pinned_version": 2, "updated_at": "2026-04-28T18:30:00Z", }); - let resp: UpdateResponse = serde_json::from_value(body).unwrap(); - assert_eq!(resp.schema_name.as_deref(), Some("sandbox_xyz")); + let resp: UpdateDatasetResponse = serde_json::from_value(body).unwrap(); + let view = UpdateView::from(resp); + assert_eq!(view.pinned_version, Some(2)); } #[test] - fn update_response_handles_pinned_version() { - let body = serde_json::json!({ - "id": "ds_abc123", - "label": "x", - "table_name": "x", - "latest_version": 5, - "pinned_version": 2, - "updated_at": "2026-04-28T18:30:00Z", - }); - let resp: UpdateResponse = serde_json::from_value(body).unwrap(); - assert_eq!(resp.pinned_version, Some(2)); + fn create_from_query_builds_sql_source() { + let source = DatasetSource::DatasetSourceOneOf2(Box::new(DatasetSourceOneOf2::new( + "SELECT 1".to_string(), + hotdata::models::dataset_source_one_of_2::Type::SqlQuery, + ))); + let json = serde_json::to_value(&source).unwrap(); + assert_eq!(json["type"], "sql_query"); + assert_eq!(json["sql"], "SELECT 1"); } #[test] - fn update_response_tolerates_missing_latest_version() { - // Defensive: treat latest_version as optional in case the server omits it. - let body = serde_json::json!({ - "id": "ds_abc123", - "label": "x", - "table_name": "x", - "updated_at": "2026-04-28T18:30:00Z", - }); - let resp: UpdateResponse = serde_json::from_value(body).unwrap(); - assert!(resp.latest_version.is_none()); + fn create_from_saved_query_builds_saved_query_source() { + let source = DatasetSource::DatasetSourceOneOf1(Box::new(DatasetSourceOneOf1::new( + "sq_123".to_string(), + hotdata::models::dataset_source_one_of_1::Type::SavedQuery, + ))); + let json = serde_json::to_value(&source).unwrap(); + assert_eq!(json["type"], "saved_query"); + assert_eq!(json["saved_query_id"], "sq_123"); } } diff --git a/src/embedding_providers.rs b/src/embedding_providers.rs index 335a741..5d17645 100644 --- a/src/embedding_providers.rs +++ b/src/embedding_providers.rs @@ -1,4 +1,7 @@ -use crate::api::ApiClient; +use crate::sdk::Api; +use hotdata::models::{ + CreateEmbeddingProviderRequest, EmbeddingProviderResponse, UpdateEmbeddingProviderRequest, +}; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -13,9 +16,19 @@ struct Provider { updated_at: String, } -#[derive(Deserialize)] -struct ListResponse { - embedding_providers: Vec, +impl From for Provider { + fn from(p: EmbeddingProviderResponse) -> Self { + Provider { + id: p.id, + name: p.name, + provider_type: p.provider_type, + config: p.config.unwrap_or(serde_json::Value::Null), + has_secret: p.has_secret, + source: p.source, + created_at: p.created_at, + updated_at: p.updated_at, + } + } } fn parse_config(raw: Option<&str>) -> Option { @@ -30,26 +43,24 @@ fn parse_config(raw: Option<&str>) -> Option { } pub fn list(workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let body: ListResponse = api.get("/embedding-providers"); + let api = Api::new(Some(workspace_id)); + let providers: Vec = crate::sdk::block(api.client().embedding_providers().list()) + .unwrap_or_else(|e| e.exit()) + .embedding_providers + .into_iter() + .map(Provider::from) + .collect(); use crossterm::style::Stylize; match format { - "json" => println!( - "{}", - serde_json::to_string_pretty(&body.embedding_providers).unwrap() - ), - "yaml" => print!( - "{}", - serde_yaml::to_string(&body.embedding_providers).unwrap() - ), + "json" => println!("{}", serde_json::to_string_pretty(&providers).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&providers).unwrap()), "table" => { - if body.embedding_providers.is_empty() { + if providers.is_empty() { eprintln!("{}", "No embedding providers found.".dark_grey()); return; } - let rows: Vec> = body - .embedding_providers + let rows: Vec> = providers .iter() .map(|p| { vec![ @@ -68,8 +79,10 @@ pub fn list(workspace_id: &str, format: &str) { } pub fn get(workspace_id: &str, id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let p: Provider = api.get(&format!("/embedding-providers/{id}")); + let api = Api::new(Some(workspace_id)); + let p: Provider = crate::sdk::block(api.client().embedding_providers().get(id)) + .unwrap_or_else(|e| e.exit()) + .into(); match format { "json" => println!("{}", serde_json::to_string_pretty(&p).unwrap()), @@ -103,28 +116,22 @@ pub fn create( ) { use crossterm::style::Stylize; - let api = ApiClient::new(Some(workspace_id)); - let mut body = serde_json::json!({ - "name": name, - "provider_type": provider_type, - }); + let api = Api::new(Some(workspace_id)); + let mut req = CreateEmbeddingProviderRequest::new(name.to_string(), provider_type.to_string()); if let Some(cfg) = parse_config(config) { - body["config"] = cfg; + req.config = Some(Some(cfg)); } if let Some(k) = api_key { - body["api_key"] = serde_json::json!(k); + req.api_key = Some(Some(k.to_string())); } if let Some(s) = secret_name { - body["secret_name"] = serde_json::json!(s); + req.secret_name = Some(Some(s.to_string())); } - let (status, resp_body) = api.post_raw("/embedding-providers", &body); - if !status.is_success() { - eprintln!("{}", crate::util::api_error(resp_body).red()); - std::process::exit(1); - } + let resp = crate::sdk::block(api.client().embedding_providers().create(req)) + .unwrap_or_else(|e| e.exit()); + let parsed = serde_json::to_value(&resp).unwrap_or_default(); - let parsed: serde_json::Value = serde_json::from_str(&resp_body).unwrap_or_default(); eprintln!("{}", "Embedding provider created.".green()); match format { "json" => println!("{}", serde_json::to_string_pretty(&parsed).unwrap()), @@ -132,10 +139,7 @@ pub fn create( "table" => { println!("id: {}", parsed["id"].as_str().unwrap_or("")); println!("name: {}", parsed["name"].as_str().unwrap_or("")); - println!( - "type: {}", - parsed["provider_type"].as_str().unwrap_or("") - ); + println!("type: {}", parsed["provider_type"].as_str().unwrap_or("")); } _ => unreachable!(), } @@ -160,22 +164,25 @@ pub fn update( std::process::exit(1); } - let api = ApiClient::new(Some(workspace_id)); - let mut body = serde_json::json!({}); + let api = Api::new(Some(workspace_id)); + let mut req = UpdateEmbeddingProviderRequest::new(); if let Some(n) = name { - body["name"] = serde_json::json!(n); + req.name = Some(Some(n.to_string())); } if let Some(cfg) = parse_config(config) { - body["config"] = cfg; + req.config = Some(Some(cfg)); } if let Some(k) = api_key { - body["api_key"] = serde_json::json!(k); + req.api_key = Some(Some(k.to_string())); } if let Some(s) = secret_name { - body["secret_name"] = serde_json::json!(s); + req.secret_name = Some(Some(s.to_string())); } - let resp: serde_json::Value = api.put(&format!("/embedding-providers/{id}"), &body); + let resp = crate::sdk::block(api.client().embedding_providers().update(id, req)) + .unwrap_or_else(|e| e.exit()); + let resp = serde_json::to_value(&resp).unwrap_or_default(); + eprintln!("{}", "Embedding provider updated.".green()); match format { "json" => println!("{}", serde_json::to_string_pretty(&resp).unwrap()), @@ -193,12 +200,8 @@ pub fn update( pub fn delete(workspace_id: &str, id: &str) { use crossterm::style::Stylize; - let api = ApiClient::new(Some(workspace_id)); - let (status, resp_body) = api.delete_raw(&format!("/embedding-providers/{id}")); - if !status.is_success() { - eprintln!("{}", crate::util::api_error(resp_body).red()); - std::process::exit(1); - } + let api = Api::new(Some(workspace_id)); + crate::sdk::block(api.client().embedding_providers().delete(id)).unwrap_or_else(|e| e.exit()); println!("{}", format!("Embedding provider '{id}' deleted.").green()); } @@ -232,26 +235,37 @@ mod tests { assert_eq!(p.config["model"], "text-embedding-3-small"); } - /// Mirrors runtimedb's `ListEmbeddingProvidersResponse`. + /// The SDK `EmbeddingProviderResponse` converts into the CLI `Provider` + /// display struct, preserving the fields the CLI prints. A null `config` + /// from the SDK collapses to JSON null so table/json output stays stable. #[test] - fn list_response_deserializes_runtimedb_payload() { - let body = serde_json::json!({ - "embedding_providers": [ - { - "id": "sys_emb_openai", - "name": "openai", - "provider_type": "service", - "config": {}, - "has_secret": true, - "source": "system", - "created_at": "2026-04-29T08:19:57Z", - "updated_at": "2026-04-29T08:19:57Z" - } - ] - }); - let resp: ListResponse = serde_json::from_value(body).unwrap(); - assert_eq!(resp.embedding_providers.len(), 1); - assert_eq!(resp.embedding_providers[0].name, "openai"); + fn sdk_response_converts_to_provider() { + let sdk = EmbeddingProviderResponse { + config: Some(serde_json::json!({"model": "text-embedding-3-small"})), + created_at: "2026-04-29T08:19:57Z".to_string(), + has_secret: true, + id: "sys_emb_openai".to_string(), + name: "openai".to_string(), + provider_type: "service".to_string(), + source: "system".to_string(), + updated_at: "2026-04-29T08:19:57Z".to_string(), + }; + let p: Provider = sdk.into(); + assert_eq!(p.id, "sys_emb_openai"); + assert_eq!(p.config["model"], "text-embedding-3-small"); + + let sdk_null = EmbeddingProviderResponse { + config: None, + created_at: String::new(), + has_secret: false, + id: "x".to_string(), + name: "n".to_string(), + provider_type: "local".to_string(), + source: "user".to_string(), + updated_at: String::new(), + }; + let p: Provider = sdk_null.into(); + assert!(p.config.is_null()); } #[test] diff --git a/src/indexes.rs b/src/indexes.rs index c670ab0..2b8ebe0 100644 --- a/src/indexes.rs +++ b/src/indexes.rs @@ -1,4 +1,4 @@ -use crate::api::ApiClient; +use crate::sdk::{Api, block, none_if_404}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -35,24 +35,12 @@ struct InfoTable { table: String, } -#[derive(Deserialize)] -struct InfoListResponse { - tables: Vec, - has_more: bool, - next_cursor: Option, -} - #[derive(Deserialize)] struct ConnectionRef { id: String, name: String, } -#[derive(Deserialize)] -struct ConnectionsBody { - connections: Vec, -} - fn connection_label_to_id_map(connections: &[ConnectionRef]) -> HashMap { let mut m = HashMap::new(); for c in connections { @@ -61,9 +49,17 @@ fn connection_label_to_id_map(connections: &[ConnectionRef]) -> HashMap HashMap { - let body: ConnectionsBody = api.get("/connections"); - connection_label_to_id_map(&body.connections) +fn connection_lookup(api: &Api) -> HashMap { + let resp = block(api.client().connections().list()).unwrap_or_else(|e| e.exit()); + let refs: Vec = resp + .connections + .into_iter() + .map(|c| ConnectionRef { + id: c.id, + name: c.name, + }) + .collect(); + connection_label_to_id_map(&refs) } /// How to continue after merging one `/information_schema` page. @@ -90,7 +86,7 @@ fn sort_info_tables(tables: &mut [InfoTable]) { } fn collect_tables( - api: &ApiClient, + api: &Api, connection_id: Option<&str>, schema: Option<&str>, table: Option<&str>, @@ -98,22 +94,22 @@ fn collect_tables( let mut out = Vec::new(); let mut cursor: Option = None; loop { - let mut params: Vec<(&str, Option)> = Vec::new(); - if let Some(id) = connection_id { - params.push(("connection_id", Some(id.to_string()))); - } - if let Some(s) = schema { - params.push(("schema", Some(s.to_string()))); - } - if let Some(t) = table { - params.push(("table", Some(t.to_string()))); - } - if let Some(ref c) = cursor { - params.push(("cursor", Some(c.clone()))); - } - let body: InfoListResponse = api.get_with_params("/information_schema", ¶ms); - out.extend(body.tables); - match information_schema_followup(body.has_more, body.next_cursor) { + let resp = block(api.client().information_schema().get( + connection_id, + schema, + table, + None, + None, + cursor.as_deref(), + )) + .unwrap_or_else(|e| e.exit()); + out.extend(resp.tables.into_iter().map(|t| InfoTable { + connection: t.connection, + schema: t.schema, + table: t.table, + })); + let next_cursor = resp.next_cursor.flatten(); + match information_schema_followup(resp.has_more, next_cursor) { ControlFlow::Break(()) => break, ControlFlow::Continue(c) => cursor = Some(c), } @@ -122,26 +118,24 @@ fn collect_tables( out } -fn list_one_table(api: &ApiClient, connection_id: &str, schema: &str, table: &str) -> Vec { +fn list_one_table(api: &Api, connection_id: &str, schema: &str, table: &str) -> Vec { + // The SDK's typed `IndexInfoResponse.status` is a closed `ready`/`pending` + // enum; the CLI accepts any status string for display. Keep the CLI's own + // tolerant deserialization via the seam's untyped GET escape hatch. let path = format!("/connections/{connection_id}/tables/{schema}/{table}/indexes"); - let body: ListResponse = api.get(&path); + let body: ListResponse = api.get_json(&path, &[]).unwrap_or_else(|e| e.exit()); body.indexes } -fn list_one_dataset(api: &ApiClient, dataset_id: &str) -> Vec { +fn list_one_dataset(api: &Api, dataset_id: &str) -> Vec { let path = format!("/datasets/{dataset_id}/indexes"); - let body: ListResponse = api.get(&path); + let body: ListResponse = api.get_json(&path, &[]).unwrap_or_else(|e| e.exit()); body.indexes } -fn list_one_table_scan( - api: &ApiClient, - connection_id: &str, - schema: &str, - table: &str, -) -> Vec { +fn list_one_table_scan(api: &Api, connection_id: &str, schema: &str, table: &str) -> Vec { let path = format!("/connections/{connection_id}/tables/{schema}/{table}/indexes"); - match api.get_none_if_not_found::(&path) { + match none_if_404(api.get_json::(&path, &[])).unwrap_or_else(|e| e.exit()) { Some(body) => body.indexes, None => Vec::new(), } @@ -164,8 +158,8 @@ fn resolve_search_params( .filter(|i| { let t = i.index_type.as_str(); (t == "bm25" || t == "vector") - && hint_type.map_or(true, |ht| ht == t) - && hint_column.map_or(true, |hc| i.columns.iter().any(|c| c == hc)) + && hint_type.is_none_or(|ht| ht == t) + && hint_column.is_none_or(|hc| i.columns.iter().any(|c| c == hc)) }) .collect(); @@ -182,9 +176,11 @@ fn resolve_search_params( } [one] => { let index_type = one.index_type.clone(); - let column = one.columns.first().cloned().ok_or_else(|| { - format!("Index '{}' has no columns.", one.index_name) - })?; + let column = one + .columns + .first() + .cloned() + .ok_or_else(|| format!("Index '{}' has no columns.", one.index_name))?; Ok((index_type, column)) } _ => { @@ -217,7 +213,7 @@ pub fn infer_for_search( ) -> (String, String) { use crossterm::style::Stylize; - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); // Resolve connection name → ID (falls back to managed database catalog lookup) let connection_id = crate::connections::resolve_connection_id(&api, connection_name); @@ -243,7 +239,7 @@ pub fn list( dataset_id: Option<&str>, format: &str, ) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let (rows, multi_table) = match (dataset_id, connection_id, schema, table) { (Some(did), _, _, _) => { @@ -373,15 +369,18 @@ impl IndexScope<'_> { } } + // Retained for path-shape regression tests; delete now routes through the + // SDK `indexes()` handle by scope variant rather than a formatted path. + #[cfg_attr(not(test), allow(dead_code))] fn delete_path(&self, index_name: &str) -> String { match self { IndexScope::Connection { connection_id, schema, table, - } => format!( - "/connections/{connection_id}/tables/{schema}/{table}/indexes/{index_name}" - ), + } => { + format!("/connections/{connection_id}/tables/{schema}/{table}/indexes/{index_name}") + } IndexScope::Dataset { dataset_id } => { format!("/datasets/{dataset_id}/indexes/{index_name}") } @@ -426,7 +425,7 @@ pub fn create( std::process::exit(1); } - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let mut body = serde_json::json!({ "index_name": name, @@ -450,7 +449,12 @@ pub fn create( body["description"] = serde_json::json!(d); } - let (status, resp_body) = api.post_raw(&scope.create_path(), &body); + // POST stays on the seam's raw helper: the SDK's `create_index` deserializes + // into `IndexInfoResponse`, which has no job `id` field, so the async-mode + // `job_id` output below could not be recovered from the typed model. + let (status, resp_body) = api + .post_raw(&scope.create_path(), &body) + .unwrap_or_else(|e| e.exit()); if !status.is_success() { eprintln!("{}", crate::util::api_error(resp_body).red()); @@ -474,11 +478,30 @@ pub fn create( pub fn delete(workspace_id: &str, scope: IndexScope<'_>, index_name: &str) { use crossterm::style::Stylize; - let api = ApiClient::new(Some(workspace_id)); - let (status, resp_body) = api.delete_raw(&scope.delete_path(index_name)); + let api = Api::new(Some(workspace_id)); + let result = match scope { + IndexScope::Connection { + connection_id, + schema, + table, + } => block( + api.client() + .indexes() + .delete_index(connection_id, schema, table, index_name), + ), + IndexScope::Dataset { dataset_id } => block( + api.client() + .indexes() + .delete_dataset_index(dataset_id, index_name), + ), + }; - if !status.is_success() { - eprintln!("{}", crate::util::api_error(resp_body).red()); + if let Err(e) = result { + let body = match e { + crate::sdk::ApiError::Status { body, .. } => body, + crate::sdk::ApiError::Transport(msg) => msg, + }; + eprintln!("{}", crate::util::api_error(body).red()); std::process::exit(1); } @@ -583,19 +606,20 @@ mod tests { fn collect_tables_single_page() { let mut server = mockito::Server::new(); let mock = server - .mock("GET", "/information_schema") + .mock("GET", "/v1/information_schema") .match_header("Authorization", "Bearer k") .match_header("X-Workspace-Id", "ws1") .with_status(200) + .with_header("content-type", "application/json") .with_body( - r#"{"tables":[ - {"connection":"c1","schema":"public","table":"z"}, - {"connection":"c1","schema":"public","table":"a"} + r#"{"count":2,"limit":100,"tables":[ + {"connection":"c1","schema":"public","table":"z","synced":true}, + {"connection":"c1","schema":"public","table":"a","synced":true} ],"has_more":false,"next_cursor":null}"#, ) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws1")); + let api = Api::test_new(&server.url(), "k", Some("ws1")); let tables = collect_tables(&api, None, None, None); mock.assert(); assert_eq!(tables.len(), 2); @@ -609,13 +633,13 @@ mod tests { let mock = server .mock( "GET", - mockito::Matcher::Regex(r"^/connections/.+/tables/.+/.+/indexes$".into()), + mockito::Matcher::Regex(r"^/v1/connections/.+/tables/.+/.+/indexes$".into()), ) .match_header("Authorization", "Bearer k") .with_status(404) .create(); - let api = ApiClient::test_new(&server.url(), "k", Some("ws")); + let api = Api::test_new(&server.url(), "k", Some("ws")); let rows = list_one_table_scan(&api, "cid", "sch", "tbl"); mock.assert(); assert!(rows.is_empty()); @@ -625,9 +649,10 @@ mod tests { fn list_one_table_returns_indexes() { let mut server = mockito::Server::new(); let mock = server - .mock("GET", "/connections/cid/tables/sch/tbl/indexes") + .mock("GET", "/v1/connections/cid/tables/sch/tbl/indexes") .match_header("Authorization", "Bearer k") .with_status(200) + .with_header("content-type", "application/json") .with_body( r#"{"indexes":[{ "index_name":"ix1", @@ -641,23 +666,54 @@ mod tests { ) .create(); - let api = ApiClient::test_new(&server.url(), "k", None); + let api = Api::test_new(&server.url(), "k", None); let rows = list_one_table(&api, "cid", "sch", "tbl"); mock.assert(); assert_eq!(rows.len(), 1); assert_eq!(rows[0].index_name, "ix1"); } + #[test] + fn list_one_table_keeps_non_enum_status_via_untyped_parse() { + // Regression: the SDK's typed `IndexStatus` only models `ready`/`pending`. + // The CLI's untyped `get_json` path must still accept any status string so + // the list display never breaks on a backend status the SDK can't model. + let mut server = mockito::Server::new(); + let mock = server + .mock("GET", "/v1/connections/cid/tables/sch/tbl/indexes") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + r#"{"indexes":[{ + "index_name":"ix1", + "index_type":"bm25", + "columns":["c1"], + "metric":null, + "status":"building", + "created_at":"2020-01-01T00:00:00Z", + "updated_at":"2020-01-01T00:00:00Z" + }]}"#, + ) + .create(); + + let api = Api::test_new(&server.url(), "k", None); + let rows = list_one_table(&api, "cid", "sch", "tbl"); + mock.assert(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].status, "building"); + } + #[test] fn list_one_table_scan_returns_indexes_on_200() { let mut server = mockito::Server::new(); let mock = server - .mock("GET", "/connections/x/tables/s/t/indexes") + .mock("GET", "/v1/connections/x/tables/s/t/indexes") .with_status(200) + .with_header("content-type", "application/json") .with_body(r#"{"indexes":[]}"#) .create(); - let api = ApiClient::test_new(&server.url(), "k", None); + let api = Api::test_new(&server.url(), "k", None); let rows = list_one_table_scan(&api, "x", "s", "t"); mock.assert(); assert!(rows.is_empty()); @@ -724,7 +780,11 @@ mod tests { let indexes = vec![make_index("sorted_idx", "sorted", &["id"])]; let result = resolve_search_params(&indexes, None, None, "db.public.t"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("No BM25 or vector index found")); + assert!( + result + .unwrap_err() + .contains("No BM25 or vector index found") + ); } #[test] @@ -743,7 +803,11 @@ mod tests { ]; let result = resolve_search_params(&indexes, None, None, "db.public.t"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Multiple search indexes found")); + assert!( + result + .unwrap_err() + .contains("Multiple search indexes found") + ); } #[test] diff --git a/src/jobs.rs b/src/jobs.rs index c99d1f0..ce1ff11 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1,4 +1,5 @@ -use crate::api::ApiClient; +use crate::sdk::Api; +use hotdata::models::{JobStatusResponse, JobType}; use serde::{Deserialize, Serialize}; #[derive(Deserialize, Serialize)] @@ -13,14 +14,44 @@ struct Job { result: Option, } -#[derive(Deserialize)] -struct ListResponse { - jobs: Vec, +impl From for Job { + fn from(j: JobStatusResponse) -> Self { + Job { + id: j.id, + job_type: j.job_type.to_string(), + status: j.status.to_string(), + attempts: j.attempts.max(0) as u64, + created_at: j.created_at, + completed_at: j.completed_at.flatten(), + error_message: j.error_message.flatten(), + result: j + .result + .flatten() + .and_then(|r| serde_json::to_value(*r).ok()), + } + } +} + +/// Map the clap-validated job-type string to the SDK enum. The CLI already +/// restricts `--type` to these values, so an unknown string is unreachable; +/// fall back to `None` (no filter) rather than panic if that ever changes. +fn parse_job_type(s: &str) -> Option { + match s { + "noop" => Some(JobType::Noop), + "data_refresh_table" => Some(JobType::DataRefreshTable), + "data_refresh_connection" => Some(JobType::DataRefreshConnection), + "dataset_refresh" => Some(JobType::DatasetRefresh), + "create_index" => Some(JobType::CreateIndex), + "create_dataset_index" => Some(JobType::CreateDatasetIndex), + _ => None, + } } pub fn get(job_id: &str, workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let job: Job = api.get(&format!("/jobs/{job_id}")); + let api = Api::new(Some(workspace_id)); + let job: Job = crate::sdk::block(api.client().jobs().get(job_id)) + .unwrap_or_else(|e| e.exit()) + .into(); match format { "json" => println!("{}", serde_json::to_string_pretty(&job).unwrap()), @@ -74,20 +105,20 @@ pub fn get(job_id: &str, workspace_id: &str, format: &str) { } fn fetch_jobs( - api: &ApiClient, + api: &Api, job_type: Option<&str>, status: Option<&str>, limit: Option, offset: Option, ) -> Vec { - let params = [ - ("job_type", job_type.map(String::from)), - ("status", status.map(String::from)), - ("limit", limit.map(|l| l.to_string())), - ("offset", offset.map(|o| o.to_string())), - ]; - let resp: ListResponse = api.get_with_params("/jobs", ¶ms); - resp.jobs + let resp = crate::sdk::block(api.client().jobs().list( + job_type.and_then(parse_job_type), + status, + limit.map(|l| l as i32), + offset.map(|o| o as i32), + )) + .unwrap_or_else(|e| e.exit()); + resp.jobs.into_iter().map(Job::from).collect() } pub fn list( @@ -99,7 +130,7 @@ pub fn list( offset: Option, format: &str, ) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let jobs = if !all && status.is_none() { // Default: show only active jobs (pending + running) @@ -108,13 +139,11 @@ pub fn list( fetch_jobs(&api, job_type, status, limit, offset) }; - let body = ListResponse { jobs }; - match format { - "json" => println!("{}", serde_json::to_string_pretty(&body.jobs).unwrap()), - "yaml" => print!("{}", serde_yaml::to_string(&body.jobs).unwrap()), + "json" => println!("{}", serde_json::to_string_pretty(&jobs).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&jobs).unwrap()), "table" => { - if body.jobs.is_empty() { + if jobs.is_empty() { use crossterm::style::Stylize; let msg = if !all && status.is_none() { "No active jobs found." @@ -123,8 +152,7 @@ pub fn list( }; eprintln!("{}", msg.dark_grey()); } else { - let rows: Vec> = body - .jobs + let rows: Vec> = jobs .iter() .map(|j| { vec![ diff --git a/src/jwt.rs b/src/jwt.rs index c7de483..a6c6aa9 100644 --- a/src/jwt.rs +++ b/src/jwt.rs @@ -369,6 +369,97 @@ pub fn ensure_access_token( Err("session expired or revoked".into()) } +/// Which credential source the [`CliTokenProvider`] serves bearers from. +/// +/// Mirrors the 4-level auth-source precedence the old `ApiClient::new` +/// applied (database env -> sandbox env -> on-disk sandbox session -> +/// user session/api_key). The wrapper (`src/sdk.rs`) picks the variant at +/// construction time; the provider re-runs the corresponding *existing* +/// blocking CLI function on every request so session.json, the 30s leeway +/// table, no-clobber for Flag/Env, and clear-on-dead-refresh stay owned by +/// the CLI — the SDK never re-implements JWT exchange. +#[derive(Debug, Clone)] +pub enum AuthMode { + /// `HOTDATA_DATABASE_TOKEN` env var (a `databases run` child). + DatabaseEnv { api_url: String }, + /// `HOTDATA_SANDBOX_TOKEN` env var (a `sandbox run` child). + SandboxEnv { api_url: String }, + /// `~/.hotdata/sandbox_session.json` is present (`sandbox set `). + SandboxSession { api_url: String }, + /// Normal user-scoped CLI session in `~/.hotdata/session.json`, with an + /// optional `hd_...` api-key fallback to mint from. + Session { + profile: config::ProfileConfig, + api_key_fallback: Option, + }, +} + +/// A CLI-owned [`BearerTokenProvider`](hotdata::auth::BearerTokenProvider) +/// installed on the SDK's `Configuration.token_provider`. +/// +/// `bearer_value` delegates to the CLI's existing *synchronous* token +/// functions (which own session.json, PKCE-minted refresh tokens, and the +/// `/o/token/` `client_id=hotdata-cli` attribution). They already return a +/// ready `eyJ...` JWT, which the SDK passes through unchanged — so the SDK's +/// own `TokenManager` is bypassed for the user-JWT path and the CLI keeps +/// full ownership of auth. The blocking functions run inside +/// `spawn_blocking` so they don't stall the wrapper's async runtime. +#[derive(Debug, Clone)] +pub struct CliTokenProvider { + mode: AuthMode, +} + +impl CliTokenProvider { + pub fn new(mode: AuthMode) -> Self { + Self { mode } + } + + /// Resolve a fresh bearer synchronously. Pure delegation to the existing + /// CLI auth functions; returns the JWT to put on the wire, or an error + /// string describing why no token could be obtained. + fn resolve_blocking(mode: &AuthMode) -> Result { + match mode { + AuthMode::DatabaseEnv { api_url } => { + crate::database_session::refresh_from_env(api_url) + .ok_or_else(|| "HOTDATA_DATABASE_TOKEN is empty".to_string()) + } + AuthMode::SandboxEnv { api_url } => { + crate::sandbox_session::refresh_from_env(api_url) + .ok_or_else(|| "HOTDATA_SANDBOX_TOKEN is empty".to_string()) + } + AuthMode::SandboxSession { api_url } => { + crate::sandbox_session::ensure_access_token(api_url) + .ok_or_else(|| "sandbox session expired".to_string()) + } + AuthMode::Session { + profile, + api_key_fallback, + } => ensure_access_token(profile, api_key_fallback.as_deref()), + } + } +} + +#[async_trait::async_trait] +impl hotdata::auth::BearerTokenProvider for CliTokenProvider { + async fn bearer_value(&self) -> Result { + let mode = self.mode.clone(); + // The CLI auth functions are blocking (reqwest::blocking I/O + file + // writes). Run them on a blocking thread so the multi-thread runtime's + // worker threads (and concurrent rayon block_on calls) aren't stalled. + let resolved = tokio::task::spawn_blocking(move || Self::resolve_blocking(&mode)) + .await + .unwrap_or_else(|e| Err(format!("token resolution task failed: {e}"))); + + resolved.map_err(|body| { + // Surface as a 401 so `Configuration::resolve_bearer_token` logs the + // cause and the request proceeds to a 401 the wrapper shapes into + // the "run hotdata auth" hint (the same end-state as the old + // ApiClient refresher returning None). + hotdata::auth::TokenExchangeError::Status { status: 401, body } + }) + } +} + #[cfg(test)] mod tests { use super::*; @@ -1051,4 +1142,136 @@ mod tests { // burn a network call on the same dead refresh token. assert!(load_session().is_none()); } + + // --- CliTokenProvider (SDK BearerTokenProvider impl) ------------------ + // + // These drive `bearer_value` through the shared multi-thread runtime, + // asserting the provider delegates to the existing CLI auth functions + // (so session.json, the 30s leeway, and `client_id=hotdata-cli` at + // `/o/token/` stay CLI-owned and the SDK only sees a ready JWT). + + use hotdata::auth::BearerTokenProvider; + + /// Resolve a provider's bearer on the shared wrapper runtime. + fn bearer(provider: &CliTokenProvider) -> Result { + crate::sdk::rt().block_on(provider.bearer_value()) + } + + fn session_provider(profile: &ProfileConfig, api_key: Option<&str>) -> CliTokenProvider { + CliTokenProvider::new(AuthMode::Session { + profile: profile.clone(), + api_key_fallback: api_key.map(String::from), + }) + } + + #[test] + fn provider_returns_cached_jwt_without_http() { + let (_tmp, _guard) = with_temp_config_dir(); + save_session(&cached_session(600, 7 * 24 * 3600)).unwrap(); + // Dead port: a network call would error, proving the fast path. + let profile = mock_profile("http://127.0.0.1:1"); + let provider = session_provider(&profile, None); + assert_eq!(bearer(&provider).unwrap(), "cached-jwt"); + } + + #[test] + fn provider_refreshes_inside_leeway_with_hotdata_cli_client_id() { + let (_tmp, _guard) = with_temp_config_dir(); + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/o/token/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("grant_type".into(), "refresh_token".into()), + mockito::Matcher::UrlEncoded("client_id".into(), "hotdata-cli".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"access_token":"refreshed-jwt","expires_in":300}"#) + .create(); + + save_session(&cached_session(5, 86400)).unwrap(); + let profile = mock_profile(&server.url()); + let provider = session_provider(&profile, None); + assert_eq!(bearer(&provider).unwrap(), "refreshed-jwt"); + m.assert(); + } + + #[test] + fn provider_mints_from_api_token_with_hotdata_cli_client_id() { + let (_tmp, _guard) = with_temp_config_dir(); + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/o/token/") + .match_body(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("grant_type".into(), "api_token".into()), + mockito::Matcher::UrlEncoded("client_id".into(), "hotdata-cli".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"access_token":"fresh-jwt","expires_in":300,"refresh_token":"r"}"#) + .create(); + + let profile = mock_profile(&server.url()); + let provider = session_provider(&profile, Some("hd_xyz")); + assert_eq!(bearer(&provider).unwrap(), "fresh-jwt"); + m.assert(); + } + + #[test] + fn provider_persists_rotated_token_to_session_0600() { + use std::os::unix::fs::PermissionsExt; + let (_tmp, _guard) = with_temp_config_dir(); + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/o/token/") + .match_body(mockito::Matcher::UrlEncoded( + "grant_type".into(), + "refresh_token".into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"access_token":"rotated-jwt","expires_in":300,"refresh_token":"r2"}"#) + .create(); + + save_session(&cached_session(-10, 86400)).unwrap(); + let profile = mock_profile(&server.url()); + let provider = session_provider(&profile, None); + assert_eq!(bearer(&provider).unwrap(), "rotated-jwt"); + + // The rotated token survives to disk for the next CLI invocation, + // still at 0600 (it carries a refresh token). + let loaded = load_session().unwrap(); + assert_eq!(loaded.access_token, "rotated-jwt"); + let path = session_path().unwrap(); + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + } + + #[test] + fn provider_surfaces_401_when_no_credential() { + let (_tmp, _guard) = with_temp_config_dir(); + // No session, no api_key fallback -> ensure_access_token errors, the + // provider maps it to a 401 so the request proceeds to the wrapper's + // "run hotdata auth" hint. + let profile = mock_profile("http://127.0.0.1:1"); + let provider = session_provider(&profile, None); + match bearer(&provider).unwrap_err() { + hotdata::auth::TokenExchangeError::Status { status, .. } => assert_eq!(status, 401), + other => panic!("expected Status 401, got {other:?}"), + } + } + + #[test] + fn provider_clears_session_when_refresh_dies_no_fallback() { + let (_tmp, _guard) = with_temp_config_dir(); + let mut server = mockito::Server::new(); + let _m = server.mock("POST", "/o/token/").with_status(400).create(); + + save_session(&cached_session(-10, 86400)).unwrap(); + let profile = mock_profile(&server.url()); + let provider = session_provider(&profile, None); + assert!(bearer(&provider).is_err()); + // Dead refresh with no fallback -> session cleared (no clobber loop). + assert!(load_session().is_none()); + } } diff --git a/src/main.rs b/src/main.rs index 7e791de..5b54dad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,3 @@ -mod api; mod auth; mod command; mod config; @@ -14,9 +13,11 @@ mod jobs; mod jwt; mod queries; mod query; +mod raw_http; mod results; mod sandbox; mod sandbox_session; +mod sdk; mod skill; mod table; mod tables; @@ -141,14 +142,13 @@ extern "C" fn print_database_footer() { if database_session::database_token_in_use().is_some() { return; } - if let Some(ws_id) = ACTIVE_WORKSPACE_ID.get() { - if let Some(id) = config::load_current_database("default", ws_id) { - eprintln!( - "{}", - format!("current database: {id} use 'hotdata databases set' to change") - .dark_grey(), - ); - } + if let Some(ws_id) = ACTIVE_WORKSPACE_ID.get() + && let Some(id) = config::load_current_database("default", ws_id) + { + eprintln!( + "{}", + format!("current database: {id} use 'hotdata databases set' to change").dark_grey(), + ); } } @@ -235,7 +235,9 @@ fn main() { } else { datasets::create_from_saved_query( &workspace_id, - query_id.as_deref().unwrap_or_else(|| unreachable!("clap enforces --sql or --query-id")), + query_id.as_deref().unwrap_or_else(|| { + unreachable!("clap enforces --sql or --query-id") + }), description.as_deref(), &name, &output, @@ -281,15 +283,13 @@ fn main() { match command { Some(QueryCommands::Status { id }) => query::poll(&id, &workspace_id, &output), None => match sql { - Some(sql) => { - query::execute( - &sql, - &workspace_id, - connection.as_deref(), - database.as_deref(), - &output, - ) - } + Some(sql) => query::execute( + &sql, + &workspace_id, + connection.as_deref(), + database.as_deref(), + &output, + ), None => { use clap::CommandFactory; let mut cmd = Cli::command(); @@ -446,12 +446,8 @@ fn main() { expires_at.as_deref(), &output, ), - Some(DatabasesCommands::Set { id }) => { - databases::set(&workspace_id, &id) - } - Some(DatabasesCommands::Unset) => { - databases::unset(&workspace_id) - } + Some(DatabasesCommands::Set { id }) => databases::set(&workspace_id, &id), + Some(DatabasesCommands::Unset) => databases::unset(&workspace_id), Some(DatabasesCommands::Delete { name_or_id }) => { databases::delete(&workspace_id, &name_or_id) } @@ -678,52 +674,63 @@ fn main() { output_column, description, } => { - let api = api::ApiClient::new(Some(&workspace_id)); - let (scope, resolved_columns, auto_name) = - match (catalog.as_deref().or(table.as_deref()), dataset_id.as_deref()) { - (Some(_), None) => { - let catalog_or_conn = catalog.as_deref().unwrap_or_else(|| { - eprintln!("error: --catalog is required"); - std::process::exit(1); - }); - let tbl = table.as_deref().unwrap_or_else(|| { - eprintln!("error: --table is required"); - std::process::exit(1); - }); - let cols = column.as_deref().unwrap_or_else(|| { - eprintln!("error: --column is required"); - std::process::exit(1); - }); - let conn_id = connections::resolve_connection_id(&api, catalog_or_conn); - let auto = format!( - "{tbl}_{cols}_{type}", - cols = cols.replace(',', "_"), - type = r#type - ); - ((conn_id, schema, tbl.to_string()), cols.to_string(), auto) - } - (None, Some(did)) => { - let cols = column.as_deref().unwrap_or_else(|| { - eprintln!("error: --column is required with --dataset-id"); - std::process::exit(1); - }); - let auto = format!( - "dataset_{cols}_{type}", - cols = cols.replace(',', "_"), - type = r#type - ); - ((did.to_string(), String::new(), String::new()), cols.to_string(), auto) - } - _ => { - eprintln!("error: provide --catalog and --table, or --dataset-id with --column"); + let api = sdk::Api::new(Some(&workspace_id)); + let (scope, resolved_columns, auto_name) = match ( + catalog.as_deref().or(table.as_deref()), + dataset_id.as_deref(), + ) { + (Some(_), None) => { + let catalog_or_conn = catalog.as_deref().unwrap_or_else(|| { + eprintln!("error: --catalog is required"); std::process::exit(1); - } - }; + }); + let tbl = table.as_deref().unwrap_or_else(|| { + eprintln!("error: --table is required"); + std::process::exit(1); + }); + let cols = column.as_deref().unwrap_or_else(|| { + eprintln!("error: --column is required"); + std::process::exit(1); + }); + let conn_id = + connections::resolve_connection_id(&api, catalog_or_conn); + let auto = format!( + "{tbl}_{cols}_{type}", + cols = cols.replace(',', "_"), + type = r#type + ); + ((conn_id, schema, tbl.to_string()), cols.to_string(), auto) + } + (None, Some(did)) => { + let cols = column.as_deref().unwrap_or_else(|| { + eprintln!("error: --column is required with --dataset-id"); + std::process::exit(1); + }); + let auto = format!( + "dataset_{cols}_{type}", + cols = cols.replace(',', "_"), + type = r#type + ); + ( + (did.to_string(), String::new(), String::new()), + cols.to_string(), + auto, + ) + } + _ => { + eprintln!( + "error: provide --catalog and --table, or --dataset-id with --column" + ); + std::process::exit(1); + } + }; let index_name = name.unwrap_or(auto_name); let is_dataset = dataset_id.is_some(); let (conn_id, schema, table) = scope; let resolved_scope = if is_dataset { - indexes::IndexScope::Dataset { dataset_id: &conn_id } + indexes::IndexScope::Dataset { + dataset_id: &conn_id, + } } else { indexes::IndexScope::Connection { connection_id: &conn_id, @@ -758,7 +765,9 @@ fn main() { schema.as_deref(), table.as_deref(), ) { - (Some(did), _, _, _) => indexes::IndexScope::Dataset { dataset_id: did }, + (Some(did), _, _, _) => { + indexes::IndexScope::Dataset { dataset_id: did } + } (None, Some(cid), Some(sch), Some(tbl)) => { indexes::IndexScope::Connection { connection_id: cid, @@ -842,9 +851,7 @@ fn main() { // Schema defaults to `public` when omitted. let parts: Vec<&str> = table.splitn(4, '.').collect(); let (conn_name, schema, table_name) = match parts.as_slice() { - [conn, schema, tbl] => { - (conn.to_string(), schema.to_string(), tbl.to_string()) - } + [conn, schema, tbl] => (conn.to_string(), schema.to_string(), tbl.to_string()), [conn, tbl] => (conn.to_string(), "public".to_string(), tbl.to_string()), _ => { eprintln!( @@ -856,23 +863,22 @@ fn main() { let normalized_table = format!("{}.{}.{}", conn_name, schema, table_name); // Infer --type and --column from the table's indexes when either is omitted. - let (resolved_type, resolved_column) = - if r#type.is_some() && column.is_some() { - (r#type.unwrap(), column.unwrap()) - } else { - let (inferred_type, inferred_column) = indexes::infer_for_search( - &workspace_id, - &conn_name, - &schema, - &table_name, - r#type.as_deref(), - column.as_deref(), - ); - ( - r#type.unwrap_or(inferred_type), - column.unwrap_or(inferred_column), - ) - }; + let (resolved_type, resolved_column) = if r#type.is_some() && column.is_some() { + (r#type.unwrap(), column.unwrap()) + } else { + let (inferred_type, inferred_column) = indexes::infer_for_search( + &workspace_id, + &conn_name, + &schema, + &table_name, + r#type.as_deref(), + column.as_deref(), + ); + ( + r#type.unwrap_or(inferred_type), + column.unwrap_or(inferred_column), + ) + }; let select_cols = select.as_deref().unwrap_or("*"); @@ -1062,12 +1068,6 @@ fn main() { update::maybe_print_update_notice(update_handle); } - -#[cfg(test)] -mod tests { - use super::*; -} - pub fn get_styles() -> clap::builder::Styles { Styles::styled() .header(AnsiColor::Yellow.on_default()) diff --git a/src/queries.rs b/src/queries.rs index d130b2c..8f6fb49 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -1,6 +1,7 @@ -use crate::api::ApiClient; +use crate::sdk::Api; use crossterm::style::Stylize; -use serde::{Deserialize, Serialize}; +use hotdata::models::QueryRunInfo; +use serde::Serialize; const SQL_KEYWORDS: &[&str] = &[ "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "IN", "IS", "NULL", "AS", "ON", "JOIN", "LEFT", @@ -110,7 +111,7 @@ fn highlight_sql(sql: &str) -> String { result } -#[derive(Deserialize, Serialize)] +#[derive(Serialize)] struct QueryRun { id: String, status: String, @@ -125,7 +126,6 @@ struct QueryRun { sql_hash: String, sql_text: String, result_id: Option, - #[serde(default)] database_id: Option, error_message: Option, warning_message: Option, @@ -133,12 +133,30 @@ struct QueryRun { user_public_id: Option, } -#[derive(Deserialize)] -struct ListResponse { - query_runs: Vec, - count: u64, - has_more: bool, - next_cursor: Option, +impl From for QueryRun { + fn from(r: QueryRunInfo) -> Self { + QueryRun { + id: r.id, + status: r.status, + created_at: r.created_at, + completed_at: r.completed_at.flatten(), + execution_time_ms: r.execution_time_ms.flatten().map(|v| v.max(0) as u64), + server_processing_ms: r.server_processing_ms.flatten().map(|v| v.max(0) as u64), + row_count: r.row_count.flatten().map(|v| v.max(0) as u64), + saved_query_id: r.saved_query_id.flatten(), + saved_query_version: r.saved_query_version.flatten().map(|v| v.max(0) as u64), + snapshot_id: r.snapshot_id, + sql_hash: r.sql_hash, + sql_text: r.sql_text, + result_id: r.result_id.flatten(), + // Not carried by the API / SDK model; preserved as a serialized null. + database_id: None, + error_message: r.error_message.flatten(), + warning_message: r.warning_message.flatten(), + trace_id: r.trace_id.flatten(), + user_public_id: r.user_public_id.flatten(), + } + } } fn truncate_sql(sql: &str, max: usize) -> String { @@ -158,27 +176,32 @@ pub fn list( status: Option<&str>, format: &str, ) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); + + let resp = crate::sdk::block(api.client().query_runs().list( + limit.map(|l| l as i32), + cursor, + status, + None, + )) + .unwrap_or_else(|e| e.exit()); - let params = [ - ("limit", limit.map(|l| l.to_string())), - ("cursor", cursor.map(str::to_string)), - ("status", status.map(str::to_string)), - ]; - let body: ListResponse = api.get_with_params("/query-runs", ¶ms); + let query_runs: Vec = resp.query_runs.into_iter().map(QueryRun::from).collect(); + let count = resp.count.max(0) as u64; + let has_more = resp.has_more; + let next_cursor = resp.next_cursor.flatten(); match format { "json" => println!( "{}", - serde_json::to_string_pretty(&body.query_runs).unwrap() + serde_json::to_string_pretty(&query_runs).unwrap() ), - "yaml" => print!("{}", serde_yaml::to_string(&body.query_runs).unwrap()), + "yaml" => print!("{}", serde_yaml::to_string(&query_runs).unwrap()), "table" => { - if body.query_runs.is_empty() { + if query_runs.is_empty() { eprintln!("{}", "No query runs found.".dark_grey()); } else { - let rows: Vec> = body - .query_runs + let rows: Vec> = query_runs .iter() .map(|r| { vec![ @@ -201,13 +224,12 @@ pub fn list( &rows, ); } - if body.has_more { - let next = body.next_cursor.as_deref().unwrap_or(""); + if has_more { + let next = next_cursor.as_deref().unwrap_or(""); eprintln!( "{}", format!( - "showing {} results — use --cursor {next} for more", - body.count + "showing {count} results — use --cursor {next} for more" ) .dark_grey() ); @@ -218,9 +240,10 @@ pub fn list( } pub fn get(query_run_id: &str, workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let path = format!("/query-runs/{query_run_id}"); - let run: QueryRun = api.get(&path); + let api = Api::new(Some(workspace_id)); + let run: QueryRun = crate::sdk::block(api.client().query_runs().get(query_run_id)) + .unwrap_or_else(|e| e.exit()) + .into(); print_detail(&run, format); } diff --git a/src/query.rs b/src/query.rs index ffe7919..f95f57a 100644 --- a/src/query.rs +++ b/src/query.rs @@ -1,4 +1,4 @@ -use crate::api::ApiClient; +use crate::sdk::Api; use serde::Deserialize; use serde_json::Value; @@ -14,18 +14,17 @@ pub struct QueryResponse { pub warning: Option, } -#[derive(Deserialize)] -struct AsyncResponse { - query_run_id: String, -} - -#[derive(Deserialize)] -struct QueryRunResponse { - id: String, - status: String, - result_id: Option, - #[serde(default)] - error_message: Option, +/// Convert the SDK's inline `QueryResponse` (200 path) into the CLI's display +/// model. The async path decodes Arrow instead (see `fetch_arrow_result`). +fn query_response_from_sdk(resp: hotdata::models::QueryResponse) -> QueryResponse { + QueryResponse { + result_id: resp.result_id.flatten(), + columns: resp.columns, + rows: resp.rows, + row_count: resp.row_count.max(0) as u64, + execution_time_ms: Some(resp.execution_time_ms.max(0) as u64), + warning: resp.warning.flatten(), + } } fn value_to_string(v: &Value) -> String { @@ -131,8 +130,15 @@ fn arrow_ipc_to_query_response(bytes: Vec, result_id: String) -> QueryRespon } /// Fetch `/results/{result_id}` as Arrow IPC and return a `QueryResponse`. -pub(crate) fn fetch_arrow_result(api: &ApiClient, result_id: &str) -> QueryResponse { - let (status, bytes) = api.get_bytes(&format!("/results/{result_id}"), ACCEPT_ARROW); +/// +/// The Arrow stream is fetched through the SDK seam ([`Api::get_bytes`]) — same +/// auth/transport as every other call — but decoded here with the CLI's own +/// pinned `arrow` crate rather than the SDK's `get_result_arrow` (whose +/// `RecordBatch` comes from a different `arrow` major version). +pub(crate) fn fetch_arrow_result(api: &Api, result_id: &str) -> QueryResponse { + let (status, bytes) = api + .get_bytes(&format!("/results/{result_id}"), ACCEPT_ARROW) + .unwrap_or_else(|e| e.exit()); if !status.is_success() { use crossterm::style::Stylize; let msg = String::from_utf8_lossy(&bytes); @@ -149,119 +155,111 @@ pub fn execute( database: Option<&str>, format: &str, ) { - let mut api = ApiClient::new(Some(workspace_id)); - if let Some(db_id) = database { - api = api.with_database(db_id); - } + let api = Api::new(Some(workspace_id)); - let mut body = serde_json::json!({ - "sql": sql, - "async": true, - "async_after_ms": 1000, - }); - if let Some(conn) = connection { - body["connection_id"] = Value::String(conn.to_string()); - } + // `--connection` is a no-op: /query is database-scoped and the endpoint has + // no connection_id field. Accepted for compatibility (see follow-up issue + // to remove it). + let _ = connection; + + // Scope to the explicit --database flag, else the active database resolved + // at construction (HOTDATA_DATABASE / current database). submit_query sends + // it as the X-Database-Id header. + let database = database.or(api.database_id()); + + let mut request = hotdata::models::QueryRequest::new(sql.to_string()); + request.r#async = Some(true); + request.async_after_ms = Some(Some(1000)); let spinner = crate::util::spinner("running query..."); - let (status, resp_body) = api.post_raw("/query", &body); + let outcome = crate::sdk::block(api.client().submit_query(request, database)) + .unwrap_or_else(|e| e.exit()); spinner.finish_and_clear(); - if status.as_u16() == 202 { - // Query didn't complete within async_after_ms — poll until done, then - // fetch the result as Arrow IPC. - let async_resp: AsyncResponse = match serde_json::from_str(&resp_body) { - Ok(r) => r, - Err(e) => { - eprintln!("error parsing async response: {e}"); - std::process::exit(1); - } - }; + let async_resp = match outcome { + // Completed within async_after_ms — inline results. + hotdata::QueryOutcome::Inline(resp) => { + print_result(&query_response_from_sdk(resp), format); + return; + } + // Still running — poll the query run, then fetch the result as Arrow. + hotdata::QueryOutcome::Submitted(async_resp) => async_resp, + // QueryOutcome is #[non_exhaustive]; guard against future variants. + _ => { + eprintln!("unexpected query response from server"); + std::process::exit(1); + } + }; - let run_id = &async_resp.query_run_id; - let spinner = crate::util::spinner("waiting for query..."); - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); + let run_id = &async_resp.query_run_id; + let spinner = crate::util::spinner("waiting for query..."); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); - loop { - let run: QueryRunResponse = api.get(&format!("/query-runs/{run_id}")); - match run.status.as_str() { - "succeeded" => { - spinner.finish_and_clear(); - match run.result_id { - Some(ref result_id) => { - let result = fetch_arrow_result(&api, result_id); - print_result(&result, format); - } - None => { - use crossterm::style::Stylize; - println!("{}", "Query succeeded but no result available.".yellow()); - } + loop { + // Drive the poll loop ourselves to preserve the 5-minute deadline and + // 500ms cadence (NOT the SDK's PollConfig defaults). + let run = crate::sdk::block(api.client().query_runs().get(run_id)) + .unwrap_or_else(|e| e.exit()); + match run.status.as_str() { + "succeeded" => { + spinner.finish_and_clear(); + match run.result_id.flatten() { + Some(ref result_id) => { + let result = fetch_arrow_result(&api, result_id); + print_result(&result, format); + } + None => { + use crossterm::style::Stylize; + println!("{}", "Query succeeded but no result available.".yellow()); } - return; - } - "failed" => { - spinner.finish_and_clear(); - use crossterm::style::Stylize; - let err = run.error_message.as_deref().unwrap_or("unknown error"); - eprintln!("{}", format!("query failed: {err}").red()); - std::process::exit(1); - } - "running" | "queued" | "pending" => {} - status => { - spinner.finish_and_clear(); - use crossterm::style::Stylize; - eprintln!("{}", format!("query status: {status}").yellow()); - eprintln!( - "{}", - format!("Check status with: hotdata query status {run_id}").dark_grey() - ); - std::process::exit(2); } + return; + } + "failed" => { + spinner.finish_and_clear(); + use crossterm::style::Stylize; + let err = run + .error_message + .flatten() + .unwrap_or_else(|| "unknown error".to_string()); + eprintln!("{}", format!("query failed: {err}").red()); + std::process::exit(1); } - if std::time::Instant::now() > deadline { + "running" | "queued" | "pending" => {} + status => { spinner.finish_and_clear(); use crossterm::style::Stylize; - eprintln!("{}", "query timed out after 5 minutes".red()); + eprintln!("{}", format!("query status: {status}").yellow()); eprintln!( "{}", format!("Check status with: hotdata query status {run_id}").dark_grey() ); - std::process::exit(1); + std::process::exit(2); } - std::thread::sleep(std::time::Duration::from_millis(500)); } - } - - if !status.is_success() { - let message = serde_json::from_str::(&resp_body) - .ok() - .and_then(|v| v["error"]["message"].as_str().map(str::to_string)) - .unwrap_or(resp_body); - use crossterm::style::Stylize; - eprintln!("{}", message.red()); - std::process::exit(1); - } - - // Fast path: query completed synchronously — response is JSON from /query. - let result: QueryResponse = match serde_json::from_str(&resp_body) { - Ok(r) => r, - Err(e) => { - eprintln!("error parsing response: {e}"); + if std::time::Instant::now() > deadline { + spinner.finish_and_clear(); + use crossterm::style::Stylize; + eprintln!("{}", "query timed out after 5 minutes".red()); + eprintln!( + "{}", + format!("Check status with: hotdata query status {run_id}").dark_grey() + ); std::process::exit(1); } - }; - - print_result(&result, format); + std::thread::sleep(std::time::Duration::from_millis(500)); + } } /// Poll a query run by ID. If succeeded and has a result_id, fetch and display the result. pub fn poll(query_run_id: &str, workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); - let run: QueryRunResponse = api.get(&format!("/query-runs/{query_run_id}")); + let run = crate::sdk::block(api.client().query_runs().get(query_run_id)) + .unwrap_or_else(|e| e.exit()); match run.status.as_str() { - "succeeded" => match run.result_id { + "succeeded" => match run.result_id.flatten() { Some(ref result_id) => { let result = fetch_arrow_result(&api, result_id); print_result(&result, format); @@ -273,7 +271,10 @@ pub fn poll(query_run_id: &str, workspace_id: &str, format: &str) { }, "failed" => { use crossterm::style::Stylize; - let err = run.error_message.as_deref().unwrap_or("unknown error"); + let err = run + .error_message + .flatten() + .unwrap_or_else(|| "unknown error".to_string()); eprintln!("{}", format!("query failed: {err}").red()); std::process::exit(1); } diff --git a/src/raw_http.rs b/src/raw_http.rs new file mode 100644 index 0000000..1dc29e9 --- /dev/null +++ b/src/raw_http.rs @@ -0,0 +1,87 @@ +//! Slim raw-HTTP helper for endpoints with no SDK operation. +//! +//! The SDK seam (`src/sdk.rs`) covers the generated API surface, but a handful +//! of endpoints must stay on hand-rolled `reqwest::blocking`: +//! +//! * the PKCE / OAuth token endpoints (`/o/token/`, `/v1/auth/token`) — owned +//! by `jwt.rs`, no SDK equivalent for the `authorization_code` grant; +//! * the session-token mints (`/v1/auth/database`, `/v1/auth/sandbox`) — a +//! distinct grant on distinct endpoints (`database_session.rs` / +//! `sandbox_session.rs`); +//! * the streaming `/files` upload (10 GB+, `--url` source, progress bar, no +//! request timeout, no 401-retry) — the SDK's `uploads().upload` is +//! `PathBuf`-only; +//! * `skill.rs`'s arbitrary-URL markdown fetch. +//! +//! This module owns the two blocking client builders (one timeout-bounded, one +//! no-timeout for uploads) and a thin bearer/header request builder. It does +//! NOT carry the old `ApiClient`'s 401-retry loop: token freshness is now the +//! `CliTokenProvider`'s job (proactive refresh at the 30s leeway), and the +//! upload path was always one-shot anyway. + +// Consumers (jwt.rs token mints, session mints, the streaming upload, +// skill.rs) are migrated to this helper incrementally; the allow keeps the +// build warning-free until those call sites land. +#![allow(dead_code)] + +use std::time::Duration; + +/// Cap on any single (non-upload) HTTP request. Connection create + synchronous +/// schema discovery against a slow remote catalog can take over a minute, so +/// this is generous; 5 minutes bounds the worst case if the server hangs. +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(300); + +/// TCP keepalive cadence. Without this, macOS drops a TCP connection that has +/// been quiet (e.g. while the server does slow synchronous work) and reqwest +/// surfaces it as "error sending request" even though the request completed +/// server-side. +const TCP_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30); + +/// JSON keys whose values are redacted in debug request/response logging. +pub const TOKEN_REDACT_KEYS: &[&str] = &["access_token", "refresh_token", "api_token", "code"]; + +/// A timeout-bounded blocking client for ordinary raw requests (token mints, +/// session mints, arbitrary GETs). +pub fn build_http_client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .timeout(HTTP_REQUEST_TIMEOUT) + .tcp_keepalive(TCP_KEEPALIVE_INTERVAL) + .build() + .expect("reqwest blocking client should always build with these defaults") +} + +/// Client used only for streaming file uploads. Deliberately has **no** request +/// timeout: an upload's duration scales with file size and uplink (a 10 GB +/// parquet takes far longer than `HTTP_REQUEST_TIMEOUT`, which is sized for +/// slow server-side work), so a wall-clock cap would abort healthy-but-slow +/// transfers. TCP keepalive is kept so a genuinely dead peer is still reaped by +/// the OS; a live-but-slow upload runs to completion and the user can Ctrl-C. +pub fn build_upload_client() -> reqwest::blocking::Client { + reqwest::blocking::Client::builder() + .tcp_keepalive(TCP_KEEPALIVE_INTERVAL) + .build() + .expect("reqwest blocking client should always build with these defaults") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn http_client_builds() { + let _ = build_http_client(); + } + + #[test] + fn upload_client_builds() { + let _ = build_upload_client(); + } + + #[test] + fn redact_keys_cover_token_fields() { + // Guards against silently dropping a sensitive key from debug logs. + assert!(TOKEN_REDACT_KEYS.contains(&"access_token")); + assert!(TOKEN_REDACT_KEYS.contains(&"refresh_token")); + assert!(TOKEN_REDACT_KEYS.contains(&"api_token")); + } +} diff --git a/src/results.rs b/src/results.rs index 66f7688..f7f13f6 100644 --- a/src/results.rs +++ b/src/results.rs @@ -1,4 +1,4 @@ -use crate::api::ApiClient; +use crate::sdk::Api; use crossterm::style::Stylize; use serde::{Deserialize, Serialize}; @@ -23,13 +23,22 @@ struct ListResponse { } pub fn list(workspace_id: &str, limit: Option, offset: Option, format: &str) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); - let params = [ - ("limit", limit.map(|l| l.to_string())), - ("offset", offset.map(|o| o.to_string())), - ]; - let body: ListResponse = api.get_with_params("/results", ¶ms); + let mut params: Vec<(&str, String)> = Vec::new(); + if let Some(l) = limit { + params.push(("limit", l.to_string())); + } + if let Some(o) = offset { + params.push(("offset", o.to_string())); + } + // The SDK's typed `results().list()` model drops `row_count`, + // `query_run_id`, and `expires_at` (the columns the CLI conditionally + // shows), so deserialize into the CLI's own `ListResponse` via the seam's + // `get_json` to preserve output byte-for-byte. + let body: ListResponse = api + .get_json("/results", ¶ms) + .unwrap_or_else(|e| e.exit()); match format { "json" => println!("{}", serde_json::to_string_pretty(&body.results).unwrap()), @@ -52,7 +61,11 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: crate::util::format_date(&r.created_at), ]; if has_rows { - row.push(r.row_count.map(|n| n.to_string()).unwrap_or_else(|| "-".to_string())); + row.push( + r.row_count + .map(|n| n.to_string()) + .unwrap_or_else(|| "-".to_string()), + ); } if has_query_run { row.push(r.query_run_id.as_deref().unwrap_or("-").to_string()); @@ -61,7 +74,7 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: row.push( r.expires_at .as_deref() - .map(|s| crate::util::format_date(s)) + .map(crate::util::format_date) .unwrap_or_else(|| "-".to_string()), ); } @@ -70,9 +83,15 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: .collect(); let mut headers = vec!["ID", "STATUS", "CREATED"]; - if has_rows { headers.push("ROWS"); } - if has_query_run { headers.push("QUERY_RUN_ID"); } - if has_expires { headers.push("EXPIRES"); } + if has_rows { + headers.push("ROWS"); + } + if has_query_run { + headers.push("QUERY_RUN_ID"); + } + if has_expires { + headers.push("EXPIRES"); + } crate::table::print(&headers, &rows); } @@ -93,7 +112,7 @@ pub fn list(workspace_id: &str, limit: Option, offset: Option, format: } pub fn get(result_id: &str, workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let result = crate::query::fetch_arrow_result(&api, result_id); crate::query::print_result(&result, format); } diff --git a/src/sandbox.rs b/src/sandbox.rs index 0572e6b..b9606e8 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -1,29 +1,11 @@ -use crate::api::ApiClient; use crate::config; use crate::sandbox_session::{self, SandboxSession}; +use crate::sdk::{block, Api, ApiError}; use crossterm::style::Stylize; -use serde::{Deserialize, Serialize}; +use hotdata::models::UpdateSandboxRequest; +use serde::Deserialize; use std::time::{SystemTime, UNIX_EPOCH}; -#[derive(Deserialize, Serialize)] -struct Sandbox { - public_id: String, - name: String, - markdown: String, - created_at: String, - updated_at: String, -} - -#[derive(Deserialize)] -struct ListResponse { - sandboxes: Vec, -} - -#[derive(Deserialize)] -struct DetailResponse { - sandbox: Sandbox, -} - /// Response shape of `/v1/auth/sandbox` and `/v1/auth/sandbox/`. #[derive(Deserialize)] struct SandboxTokenResponse { @@ -41,6 +23,30 @@ fn now_unix() -> u64 { .unwrap_or(0) } +/// Mint (or re-mint) a sandbox-scoped JWT via `POST /v1/auth/sandbox`. +/// +/// This token-mint endpoint has no SDK operation, so it stays on the raw seam. +/// [`Api::post_raw`] still carries the user bearer + `X-Workspace-Id` like every +/// SDK call. Reproduces the old `ApiClient::post` behavior exactly: a transport +/// error or a non-success status prints the standard error and exits, and a +/// malformed body exits the same way `parse_json` did. +fn mint_sandbox_token(api: &Api, body: &serde_json::Value) -> SandboxTokenResponse { + let (status, resp_body) = api + .post_raw("/auth/sandbox", body) + .unwrap_or_else(|e| e.exit()); + if !status.is_success() { + ApiError::Status { + status, + body: resp_body, + } + .exit(); + } + serde_json::from_str(&resp_body).unwrap_or_else(|e| { + eprintln!("error parsing response: {e}"); + std::process::exit(1); + }) +} + fn persist_sandbox_session(resp: SandboxTokenResponse, workspace_id: &str) { let now = now_unix(); let session = SandboxSession { @@ -57,8 +63,8 @@ fn persist_sandbox_session(resp: SandboxTokenResponse, workspace_id: &str) { } pub fn list(workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let body: ListResponse = api.get("/sandboxes"); + let api = Api::new(Some(workspace_id)); + let body = block(api.client().sandboxes().list()).unwrap_or_else(|e| e.exit()); let current_sandbox = std::env::var("HOTDATA_SANDBOX") .ok() @@ -96,10 +102,9 @@ pub fn list(workspace_id: &str, format: &str) { } pub fn get(sandbox_id: &str, workspace_id: &str, format: &str) { - let api = ApiClient::new(Some(workspace_id)); - let path = format!("/sandboxes/{sandbox_id}"); - let body: DetailResponse = api.get(&path); - let s = &body.sandbox; + let api = Api::new(Some(workspace_id)); + let body = block(api.client().sandboxes().get(sandbox_id)).unwrap_or_else(|e| e.exit()); + let s = &*body.sandbox; match format { "json" => println!("{}", serde_json::to_string_pretty(s).unwrap()), @@ -129,9 +134,8 @@ pub fn get(sandbox_id: &str, workspace_id: &str, format: &str) { } pub fn read(sandbox_id: &str, workspace_id: &str) { - let api = ApiClient::new(Some(workspace_id)); - let path = format!("/sandboxes/{sandbox_id}"); - let body: DetailResponse = api.get(&path); + let api = Api::new(Some(workspace_id)); + let body = block(api.client().sandboxes().get(sandbox_id)).unwrap_or_else(|e| e.exit()); if body.sandbox.markdown.is_empty() { eprintln!("{}", "Sandbox markdown is empty.".dark_grey()); } else { @@ -178,7 +182,7 @@ fn find_sandbox_run_ancestor_inner() -> Option { pub fn new(workspace_id: &str, name: Option<&str>, format: &str) { check_sandbox_lock(); - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let mut body = serde_json::json!({}); if let Some(n) = name { @@ -186,8 +190,10 @@ pub fn new(workspace_id: &str, name: Option<&str>, format: &str) { } // POST /auth/sandbox creates the sandbox AND mints a sandbox-scoped - // JWT (+ refresh token) in one round-trip. - let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); + // JWT (+ refresh token) in one round-trip. This token-mint endpoint has + // no SDK operation, so it stays on the raw seam (which still carries the + // user bearer + X-Workspace-Id like every SDK call). + let resp = mint_sandbox_token(&api, &body); let sandbox_id = resp.sandbox_id.clone(); persist_sandbox_session(resp, workspace_id); @@ -224,19 +230,16 @@ pub fn update( std::process::exit(1); } - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); - let mut body = serde_json::json!({}); - if let Some(n) = name { - body["name"] = serde_json::json!(n); - } - if let Some(m) = markdown { - body["markdown"] = serde_json::json!(m); - } + let request = UpdateSandboxRequest { + name: name.map(String::from), + markdown: markdown.map(String::from), + }; - let path = format!("/sandboxes/{sandbox_id}"); - let resp: DetailResponse = api.patch(&path, &body); - let s = &resp.sandbox; + let resp = + block(api.client().sandboxes().update(sandbox_id, request)).unwrap_or_else(|e| e.exit()); + let s = &*resp.sandbox; eprintln!("{}", "Sandbox updated".green()); match format { @@ -258,7 +261,7 @@ pub fn update( pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd: &[String]) { check_sandbox_lock(); - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); // Mint (or re-mint, for an existing sandbox) a sandbox-scoped JWT // by dispatching on grant_type at /auth/sandbox. Either way we @@ -277,7 +280,7 @@ pub fn run(sandbox_id: Option<&str>, workspace_id: &str, name: Option<&str>, cmd b } }; - let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); + let resp = mint_sandbox_token(&api, &body); let sid = resp.sandbox_id.clone(); let sandbox_jwt = resp.token.clone(); @@ -313,12 +316,12 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { // the grant_type=existing_sandbox dispatch. The call // doubles as an existence + access check (404/403 if the // user can't reach it). - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); let body = serde_json::json!({ "grant_type": "existing_sandbox", "sandbox_id": id, }); - let resp: SandboxTokenResponse = api.post("/auth/sandbox", &body); + let resp = mint_sandbox_token(&api, &body); persist_sandbox_session(resp, workspace_id); if let Err(e) = config::save_sandbox("default", id) { @@ -342,14 +345,8 @@ pub fn set(sandbox_id: Option<&str>, workspace_id: &str) { pub fn delete(sandbox_id: &str, workspace_id: &str) { check_sandbox_lock(); - let api = ApiClient::new(Some(workspace_id)); - let path = format!("/sandboxes/{sandbox_id}"); - let (status, resp_body) = api.delete_raw(&path); - - if !status.is_success() { - eprintln!("{}", crate::util::api_error(resp_body).red()); - std::process::exit(1); - } + let api = Api::new(Some(workspace_id)); + block(api.client().sandboxes().delete(sandbox_id)).unwrap_or_else(|e| e.exit()); // If the deleted sandbox was the active one, clear the cached session // and config pointer so subsequent commands don't keep routing through diff --git a/src/sandbox_session.rs b/src/sandbox_session.rs index 262f40f..3a4c0df 100644 --- a/src/sandbox_session.rs +++ b/src/sandbox_session.rs @@ -194,7 +194,7 @@ pub fn sandbox_token_in_use() -> Option<(String, Option)> { } /// In-child equivalent of [`ensure_access_token`] that operates on env -/// vars only — used by [`crate::api::ApiClient`] when the parent +/// vars only — used by [`crate::sdk::Api`] when the parent /// `sandbox run` already passed in `HOTDATA_SANDBOX_TOKEN` and /// `HOTDATA_SANDBOX_REFRESH_TOKEN`. The new tokens are *not* persisted /// to disk: the child may not have write access to the parent's diff --git a/src/sdk.rs b/src/sdk.rs new file mode 100644 index 0000000..03ce01a --- /dev/null +++ b/src/sdk.rs @@ -0,0 +1,1181 @@ +//! Synchronous wrapper over the async Hotdata Rust SDK. +//! +//! This module is the seam that replaces the hand-rolled legacy +//! `ApiClient`. The 15 command modules stay +//! synchronous and call [`Api`] methods; [`Api`] drives the async SDK behind a +//! process-global multi-thread tokio runtime via `block_on`. +//! +//! # Concurrency contract +//! +//! [`Api`] is `Send + Sync + Clone` because `indexes.rs` clones it into a rayon +//! `par_iter` and calls wrapper methods concurrently from worker threads. Each +//! worker calls [`rt().block_on(..)`](rt) on the *shared multi-thread* runtime. +//! A multi-thread runtime tolerates concurrent `block_on` from many non-runtime +//! threads (a `current_thread` runtime would panic). Wrapper methods are plain +//! sync fns that are never themselves inside a runtime task, so `block_on` +//! never nests. +//! +//! # Auth +//! +//! Construction reproduces the old `ApiClient::new` 4-level auth-source +//! precedence by choosing the [`AuthMode`](crate::jwt::AuthMode) the installed +//! [`CliTokenProvider`](crate::jwt::CliTokenProvider) will serve. The provider +//! returns a ready CLI-minted JWT (`client_id=hotdata-cli`, `/o/token/`), which +//! the SDK passes through unchanged; the CLI keeps full ownership of +//! session.json and the refresh table. + +use std::sync::Arc; +use std::sync::OnceLock; + +use hotdata::Client; +use hotdata::apis::configuration::{ApiKey, Configuration}; +use hotdata::apis::{Error, ResponseContent}; + +use crate::auth; +use crate::config; +use crate::jwt::{AuthMode, CliTokenProvider}; +use crate::util; + +/// Process-global multi-thread runtime shared by all [`Api`] clones. +/// +/// Multi-thread is required: rayon worker threads call `block_on` on this same +/// runtime concurrently. `OnceLock` makes initialization lazy and one-shot. +static RT: OnceLock = OnceLock::new(); + +/// Lazily initialize and borrow the shared runtime. +pub fn rt() -> &'static tokio::runtime::Runtime { + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("multi-thread tokio runtime should always build") + }) +} + +/// Synchronous handle over the Hotdata SDK `Client`. +/// +/// Cheap to clone (`Arc`); all clones share one `Configuration` — one +/// `token_provider`, one reqwest connection pool — across rayon workers. +#[derive(Clone)] +pub struct Api { + client: Arc, + /// API base URL as configured (carries the `/v1` suffix; used by the raw + /// session-token mints, which target `/v1/auth/*` directly). + pub api_url: String, + workspace_id: Option, + /// Sandbox/session id, sent as `X-Session-Id` to scope requests to a + /// sandbox. `None` when no sandbox is active. + session_id: Option, + database_id: Option, +} + +/// Request timeout for SDK-routed calls. Mirrors the old `ApiClient` so a hung +/// server cannot stall the CLI indefinitely. The streaming `/files` upload +/// keeps its own no-timeout client on the raw-HTTP path. +const HTTP_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); +/// TCP keepalive probe interval, matching the old client. +const TCP_KEEPALIVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30); + +/// Build the `reqwest::Client` backing every SDK call, with a request timeout + +/// TCP keepalive. The CLI shares the SDK's reqwest 0.13, so this is the exact +/// type `Configuration.client` expects. +fn sdk_http_client() -> reqwest::Client { + reqwest::Client::builder() + .timeout(HTTP_REQUEST_TIMEOUT) + .tcp_keepalive(TCP_KEEPALIVE_INTERVAL) + .build() + .expect("reqwest client with timeout should build") +} + +// Compile-time guarantee that the rayon bound can never silently regress. +const _: fn() = || { + fn assert_send_sync_clone() {} + assert_send_sync_clone::(); +}; + +/// SDK -> CLI error after mapping an `Error`. +/// +/// Carries enough to reproduce the old `fail_response` behavior: the HTTP +/// status and a printable body, or a transport/parse description. +#[derive(Debug)] +pub enum ApiError { + /// The server returned a non-success status. + Status { + status: reqwest::StatusCode, + body: String, + }, + /// Transport/serialization/IO failure with no HTTP status. + Transport(String), +} + +impl ApiError { + /// Map any SDK `Error` into an [`ApiError`]. + /// + /// `ResponseError` carries the HTTP status + raw body the CLI's + /// `format_fail_message` consumes; everything else collapses to a + /// transport description (the old "error connecting to API" / "error + /// parsing response" paths). + pub fn from_sdk(err: Error) -> Self { + match err { + Error::ResponseError(ResponseContent { + status, content, .. + }) => ApiError::Status { + status, + body: content, + }, + Error::Reqwest(e) => ApiError::Transport(format!("error connecting to API: {e}")), + Error::Serde(e) => ApiError::Transport(format!("error parsing response: {e}")), + Error::Io(e) => ApiError::Transport(format!("error connecting to API: {e}")), + } + } + + /// Print the standard error and exit, reproducing `ApiClient::fail_response`. + /// + /// On a 4xx, re-probe the auth status so a masked 404/403 is upgraded into + /// the "run hotdata auth" hint; otherwise surface the server body. + pub fn exit(&self) -> ! { + match self { + ApiError::Status { status, body } => { + let auth_status = if status.is_client_error() { + config::load("default") + .ok() + .map(|pc| auth::check_status(&pc)) + } else { + None + }; + eprintln!( + "{}", + crossterm::style::Stylize::red( + format_fail_message(*status, body, auth_status.as_ref()).as_str() + ) + ); + } + ApiError::Transport(msg) => { + eprintln!("{msg}"); + } + } + std::process::exit(1); + } +} + +/// Run an SDK future to completion on the shared runtime, mapping errors. +pub fn block(fut: F) -> Result +where + F: std::future::Future>>, + E: std::fmt::Debug, +{ + rt().block_on(fut).map_err(ApiError::from_sdk) +} + +/// Map a result, returning `Ok(None)` on HTTP 404 instead of an error. +/// +/// Reproduces `ApiClient::get_none_if_not_found` / the context-404 / indexes-404 +/// semantics: a missing resource is normal for these probes. +pub fn none_if_404(r: Result) -> Result, ApiError> { + match r { + Ok(v) => Ok(Some(v)), + Err(ApiError::Status { status, .. }) if status == reqwest::StatusCode::NOT_FOUND => { + Ok(None) + } + Err(e) => Err(e), + } +} + +/// Normalize a configured `api_url` into the SDK `base_path`. +/// +/// The CLI's `api_url` carries a `/v1` suffix (`DEFAULT_API_URL`), but every +/// generated SDK op appends its own `/v1` to `base_path`, and the seam's raw +/// helpers ([`Api::get_json`] etc.) prepend `/v1` too. Passing the `/v1`-suffixed +/// url through verbatim would produce `/v1/v1/...` on every call. Strip one +/// trailing `/v1` (and any trailing slash) so both paths resolve to a single +/// `/v1`. Session-token mints are unaffected: they use the full `self.api_url` +/// to hit `/v1/auth/*` directly. +fn sdk_base_path(api_url: &str) -> String { + let trimmed = api_url.trim_end_matches('/'); + trimmed.strip_suffix("/v1").unwrap_or(trimmed).to_string() +} + +/// Apply the seam's common request headers to a raw `RequestBuilder`: User-Agent, +/// the `X-Workspace-Id` api_key, the sandbox `X-Session-Id` and database +/// `X-Database-Id` scope, and the resolved bearer. Generated SDK ops inject the +/// api_key headers themselves; the raw seam helpers ([`Api::get_json`] etc.) +/// bypass the generated client, so they funnel through this one place rather +/// than repeating the block per verb. +async fn apply_seam_headers( + mut req: reqwest::RequestBuilder, + cfg: &Configuration, + session_id: Option<&str>, + database_id: Option<&str>, +) -> reqwest::RequestBuilder { + if let Some(ref user_agent) = cfg.user_agent { + req = req.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(apikey) = cfg.api_keys.get(hotdata::client::WORKSPACE_ID_HEADER) { + let value = match apikey.prefix { + Some(ref prefix) => format!("{} {}", prefix, apikey.key), + None => apikey.key.clone(), + }; + req = req.header(hotdata::client::WORKSPACE_ID_HEADER, value); + } + // Sandbox session scope (also forwarded from api_keys on generated ops). + if let Some(sid) = session_id { + req = req.header("X-Session-Id", sid); + } + // Database scope — generated ops don't forward it, so the seam must + // (e.g. `hotdata query --database`). + if let Some(db) = database_id { + req = req.header("X-Database-Id", db); + } + if let Some(token) = cfg.resolve_bearer_token().await { + req = req.bearer_auth(token); + } + req +} + +impl Api { + /// Build an [`Api`], reproducing `ApiClient::new`'s auth-source precedence + /// by selecting the [`AuthMode`] the installed provider will serve. Exits + /// with a diagnostic if config can't load or no usable credential exists, + /// matching the old startup behavior. + pub fn new(workspace_id: Option<&str>) -> Self { + let profile_config = match config::load("default") { + Ok(c) => c, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + let api_url = profile_config.api_url.to_string(); + + // Auth-source precedence (verbatim from the old ApiClient::new): + // 1. HOTDATA_DATABASE_TOKEN env (databases run child) + // 2. HOTDATA_SANDBOX_TOKEN env (sandbox run child) + // 3. ~/.hotdata/sandbox_session.json present (sandbox set ) + // 4. ~/.hotdata/session.json + optional api_key fallback + // + // We pre-flight the same way the old client did (so a dead/unusable + // credential exits at startup with the right hint), then hand the + // CliTokenProvider the matching mode to re-resolve on every request. + let mode = if std::env::var("HOTDATA_DATABASE_TOKEN").is_ok() { + if crate::database_session::refresh_from_env(&api_url).is_none() { + eprintln!( + "{}", + crossterm::style::Stylize::red("error: HOTDATA_DATABASE_TOKEN is empty") + ); + std::process::exit(1); + } + AuthMode::DatabaseEnv { + api_url: api_url.clone(), + } + } else if std::env::var("HOTDATA_SANDBOX_TOKEN").is_ok() { + if crate::sandbox_session::refresh_from_env(&api_url).is_none() { + eprintln!( + "{}", + crossterm::style::Stylize::red("error: HOTDATA_SANDBOX_TOKEN is empty") + ); + std::process::exit(1); + } + AuthMode::SandboxEnv { + api_url: api_url.clone(), + } + } else if crate::sandbox_session::load().is_some() { + if crate::sandbox_session::ensure_access_token(&api_url).is_none() { + use crossterm::style::Stylize; + eprintln!("{}", "error: sandbox session expired".red()); + eprintln!( + "Run {} to clear it, or {} to re-mint.", + "hotdata sandbox set".cyan(), + "hotdata sandbox set ".cyan(), + ); + std::process::exit(1); + } + AuthMode::SandboxSession { + api_url: api_url.clone(), + } + } else { + let api_key_fallback = profile_config + .api_key + .as_deref() + .filter(|k| !k.is_empty() && *k != "PLACEHOLDER") + .map(String::from); + + if let Err(e) = + crate::jwt::ensure_access_token(&profile_config, api_key_fallback.as_deref()) + { + use crossterm::style::Stylize; + eprintln!("{}", format!("error: {e}").red()); + eprintln!( + "Run {} to log in, or pass --api-key.", + "hotdata auth".cyan() + ); + std::process::exit(1); + } + AuthMode::Session { + profile: profile_config.clone(), + api_key_fallback, + } + }; + + // Resolve the sandbox/session id exactly as the old ApiClient::new did: + // HOTDATA_SANDBOX wins; otherwise, if we are a descendant of a + // `sandbox run` whose sandbox context was lost, exit (a restart is + // required); else fall back to the persisted sandbox in config. This id + // is sent as X-Session-Id to scope requests to the sandbox. + let session_id = std::env::var("HOTDATA_SANDBOX").ok().or_else(|| { + if crate::sandbox::find_sandbox_run_ancestor().is_some() { + eprintln!("error: sandbox has been lost -- restart the process"); + std::process::exit(1); + } + profile_config.sandbox.clone() + }); + + let database_id = std::env::var("HOTDATA_DATABASE").ok().or_else(|| { + workspace_id.and_then(|ws| crate::config::load_current_database("default", ws)) + }); + + Self::from_configuration( + &api_url, + workspace_id.map(String::from), + session_id, + database_id, + CliTokenProvider::new(mode), + ) + } + + /// Build the SDK `Configuration` directly (base_path, token_provider, + /// X-Workspace-Id api_key) and wrap it. Shared by `new` and tests. + fn from_configuration( + api_url: &str, + workspace_id: Option, + session_id: Option, + database_id: Option, + provider: CliTokenProvider, + ) -> Self { + let mut configuration = Configuration { + base_path: sdk_base_path(api_url), + client: sdk_http_client(), + // Attribute CLI traffic as the CLI, not the SDK default + // (`hotdata-rust/...`). The old ApiClient sent no User-Agent; an + // explicit CLI agent is the correct attribution. + user_agent: Some(format!("hotdata-cli/{}", env!("CARGO_PKG_VERSION"))), + ..Configuration::default() + }; + configuration.token_provider = Some(Arc::new(provider)); + if let Some(ref ws) = workspace_id { + configuration.api_keys.insert( + hotdata::client::WORKSPACE_ID_HEADER.to_string(), + ApiKey { + prefix: None, + key: ws.clone(), + }, + ); + } + // Scope generated SDK ops to the sandbox session: the SDK forwards + // X-Session-Id from api_keys. + if let Some(ref sid) = session_id { + configuration.api_keys.insert( + hotdata::client::SESSION_ID_HEADER.to_string(), + ApiKey { + prefix: None, + key: sid.clone(), + }, + ); + } + + Api { + client: Arc::new(Client::from_configuration(configuration)), + api_url: api_url.to_string(), + workspace_id, + session_id, + database_id, + } + } + + /// Test-only constructor: build an [`Api`] against a mock server with a + /// static bearer (no config load, no token provider). The SDK's + /// `resolve_bearer_token` falls back to `bearer_access_token` when no + /// provider is installed, so requests carry `Authorization: Bearer `. + #[cfg(test)] + pub(crate) fn test_new(api_url: &str, bearer: &str, workspace_id: Option<&str>) -> Self { + let mut configuration = Configuration { + base_path: sdk_base_path(api_url), + bearer_access_token: Some(bearer.to_string()), + ..Configuration::default() + }; + let workspace_id = workspace_id.map(String::from); + if let Some(ref ws) = workspace_id { + configuration.api_keys.insert( + hotdata::client::WORKSPACE_ID_HEADER.to_string(), + ApiKey { + prefix: None, + key: ws.clone(), + }, + ); + } + Api { + client: Arc::new(Client::from_configuration(configuration)), + api_url: api_url.to_string(), + workspace_id, + session_id: None, + database_id: None, + } + } + + /// Test-only constructor that also scopes the client to a sandbox session + /// and/or database, so tests can assert the `X-Session-Id` (api_keys, on + /// generated ops) and `X-Database-Id` headers reach the wire. + #[cfg(test)] + pub(crate) fn test_new_scoped( + api_url: &str, + bearer: &str, + workspace_id: Option<&str>, + session_id: Option<&str>, + database_id: Option<&str>, + ) -> Self { + let mut configuration = Configuration { + base_path: sdk_base_path(api_url), + bearer_access_token: Some(bearer.to_string()), + ..Configuration::default() + }; + if let Some(ws) = workspace_id { + configuration.api_keys.insert( + hotdata::client::WORKSPACE_ID_HEADER.to_string(), + ApiKey { + prefix: None, + key: ws.to_string(), + }, + ); + } + if let Some(sid) = session_id { + configuration.api_keys.insert( + hotdata::client::SESSION_ID_HEADER.to_string(), + ApiKey { + prefix: None, + key: sid.to_string(), + }, + ); + } + Api { + client: Arc::new(Client::from_configuration(configuration)), + api_url: api_url.to_string(), + workspace_id: workspace_id.map(String::from), + session_id: session_id.map(String::from), + database_id: database_id.map(String::from), + } + } + + + pub fn workspace_id(&self) -> Option<&str> { + self.workspace_id.as_deref() + } + + pub fn database_id(&self) -> Option<&str> { + self.database_id.as_deref() + } + + /// Borrow the underlying SDK client (for command modules calling resource + /// handles directly through [`block`]). + pub fn client(&self) -> &Client { + &self.client + } + + /// Resolve the current bearer token synchronously by driving the installed + /// `token_provider` on the shared runtime. + /// + /// Used by raw-HTTP paths that the SDK can't serve (the streaming `/files` + /// upload) but that still need the same `Authorization: Bearer ` the + /// SDK installs on every call. Returns `None` if no provider/static token + /// is configured. + pub fn current_bearer(&self) -> Option { + let cfg = self.client.configuration(); + rt().block_on(cfg.resolve_bearer_token()) + } + + /// Issue an authenticated `GET {base}/v1{path}` through the SDK + /// `Configuration` and deserialize the JSON body into a CLI-owned type. + /// + /// Used where the generated SDK model is lossy (drops fields the CLI + /// displays) so the seam still owns auth/transport — same reqwest client, + /// bearer via the `token_provider`, and `X-Workspace-Id` header as every + /// other SDK call — while the CLI keeps its own typed deserialization. The + /// `connections_new`-style "keep untyped parsing when the SDK model omits + /// fields" escape hatch, applied here for `GET /results`. + /// + /// `query` is appended verbatim as `(name, value)` pairs (already filtered + /// to present values by the caller). + pub fn get_json( + &self, + path: &str, + query: &[(&str, String)], + ) -> Result { + let cfg = self.client.configuration(); + let url = format!("{}/v1{path}", cfg.base_path); + let database_id = self.database_id.clone(); + let session_id = self.session_id.clone(); + rt().block_on(async move { + let mut req = cfg.client.request(reqwest::Method::GET, &url); + if !query.is_empty() { + req = req.query(query); + } + req = apply_seam_headers(req, cfg, session_id.as_deref(), database_id.as_deref()).await; + + let resp = req + .send() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + if !status.is_success() { + return Err(ApiError::Status { status, body }); + } + serde_json::from_str(&body) + .map_err(|e| ApiError::Transport(format!("error parsing response: {e}"))) + }) + } + + /// Issue an authenticated `POST {base}/v1{path}` with a JSON body through + /// the SDK `Configuration`, returning the raw status + body text. + /// + /// The seam's POST counterpart to [`get_json`](Self::get_json): used where + /// the generated SDK response model is an untagged enum whose variant + /// selection could differ from the CLI's hand-rolled field-probing + /// (`/refresh`), so the CLI keeps parsing the raw JSON itself while the seam + /// still owns auth/transport (same reqwest client, bearer, `X-Workspace-Id`). + pub fn post_raw( + &self, + path: &str, + body: &serde_json::Value, + ) -> Result<(reqwest::StatusCode, String), ApiError> { + let cfg = self.client.configuration(); + let url = format!("{}/v1{path}", cfg.base_path); + let database_id = self.database_id.clone(); + let session_id = self.session_id.clone(); + rt().block_on(async move { + let mut req = cfg.client.request(reqwest::Method::POST, &url).json(body); + req = apply_seam_headers(req, cfg, session_id.as_deref(), database_id.as_deref()).await; + + let resp = req + .send() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + Ok((status, body)) + }) + } + + /// Issue an authenticated `DELETE {base}/v1{path}` through the SDK + /// `Configuration`, returning the raw status + body text. + /// + /// The seam's DELETE counterpart to [`post_raw`](Self::post_raw): used by + /// `databases.rs`, where the delete bodies feed the same CLI-side + /// `(status, body)` control flow as the old raw `delete_raw` (e.g. the + /// delete+recreate path inspects the failure body), so non-success is + /// returned as `Ok((status, body))` rather than an error. + pub fn delete_raw(&self, path: &str) -> Result<(reqwest::StatusCode, String), ApiError> { + let cfg = self.client.configuration(); + let url = format!("{}/v1{path}", cfg.base_path); + let database_id = self.database_id.clone(); + let session_id = self.session_id.clone(); + rt().block_on(async move { + let mut req = cfg.client.request(reqwest::Method::DELETE, &url); + req = apply_seam_headers(req, cfg, session_id.as_deref(), database_id.as_deref()).await; + + let resp = req + .send() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + let status = resp.status(); + let body = resp + .text() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + Ok((status, body)) + }) + } + + /// Issue an authenticated `GET {base}/v1{path}` with a custom `Accept` + /// header through the SDK `Configuration`, returning the raw status + body + /// bytes. + /// + /// The seam's binary-body counterpart to [`get_json`](Self::get_json): used + /// for the Arrow IPC result fetch (`/results/{id}`), where the CLI decodes + /// the stream itself with its own pinned `arrow` crate version rather than + /// the SDK's `get_result_arrow` (which returns a `RecordBatch` from a + /// different `arrow` major). The seam still owns auth/transport (same + /// reqwest client, bearer via the `token_provider`, `X-Workspace-Id`). + pub fn get_bytes( + &self, + path: &str, + accept: &str, + ) -> Result<(reqwest::StatusCode, Vec), ApiError> { + let cfg = self.client.configuration(); + let url = format!("{}/v1{path}", cfg.base_path); + let database_id = self.database_id.clone(); + let session_id = self.session_id.clone(); + let accept = accept.to_string(); + rt().block_on(async move { + let mut req = cfg + .client + .request(reqwest::Method::GET, &url) + .header(reqwest::header::ACCEPT, accept); + req = apply_seam_headers(req, cfg, session_id.as_deref(), database_id.as_deref()).await; + + let resp = req + .send() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + let status = resp.status(); + let bytes = resp + .bytes() + .await + .map_err(|e| ApiError::Transport(format!("error connecting to API: {e}")))?; + Ok((status, bytes.to_vec())) + }) + } + + // --- Sample migrated call (workspace.rs uses this) ----------------------- + + /// List workspaces visible to the authenticated principal. + pub fn list_workspaces( + &self, + organization_public_id: Option<&str>, + ) -> Result { + block(self.client.workspaces().list(organization_public_id)) + } +} + +/// Decide what error text to print for a failed response. Pure function so the +/// 4xx-to-re-auth-hint heuristic is unit-testable without HTTP or `exit`. +/// +/// Relocated verbatim from the old `api.rs`. +pub fn format_fail_message( + status: reqwest::StatusCode, + body: &str, + auth_status: Option<&auth::AuthStatus>, +) -> String { + if status.is_client_error() + && let Some(auth::AuthStatus::Invalid(_)) = auth_status + { + return "error: API key is invalid. Run 'hotdata auth login' (or 'hotdata auth') to re-authenticate.".to_string(); + } + util::api_error(body.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use auth::AuthStatus; + + // --- format_fail_message: ported verbatim from api.rs (9 cases) ---------- + + #[test] + fn format_fail_message_401_with_invalid_key_shows_reauth_hint() { + let msg = format_fail_message( + reqwest::StatusCode::UNAUTHORIZED, + "", + Some(&AuthStatus::Invalid(401)), + ); + assert!(msg.contains("API key is invalid")); + assert!(msg.contains("hotdata auth login") || msg.contains("hotdata auth")); + } + + #[test] + fn format_fail_message_404_with_invalid_key_shows_reauth_hint() { + let msg = format_fail_message( + reqwest::StatusCode::NOT_FOUND, + "", + Some(&AuthStatus::Invalid(401)), + ); + assert!(msg.contains("API key is invalid"), "got: {msg}"); + } + + #[test] + fn format_fail_message_404_with_valid_key_shows_real_error() { + let body = r#"{"error":{"message":"Query run 'qrun_notreal' not found"}}"#; + let msg = format_fail_message( + reqwest::StatusCode::NOT_FOUND, + body, + Some(&AuthStatus::Authenticated), + ); + assert!(!msg.contains("API key is invalid")); + assert!(msg.contains("Query run 'qrun_notreal' not found")); + } + + #[test] + fn format_fail_message_400_with_valid_key_shows_real_error() { + let body = r#"{"error":{"message":"invalid_sql"}}"#; + let msg = format_fail_message( + reqwest::StatusCode::BAD_REQUEST, + body, + Some(&AuthStatus::Authenticated), + ); + assert_eq!(msg, "invalid_sql"); + } + + #[test] + fn format_fail_message_5xx_never_shows_reauth_hint() { + let msg = format_fail_message( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + "server exploded", + None, + ); + assert!(!msg.contains("API key is invalid")); + assert_eq!(msg, "server exploded"); + } + + #[test] + fn format_fail_message_4xx_connection_error_on_probe_falls_through() { + let body = r#"{"error":{"message":"forbidden"}}"#; + let msg = format_fail_message( + reqwest::StatusCode::FORBIDDEN, + body, + Some(&AuthStatus::ConnectionError("tcp reset".to_string())), + ); + assert!(!msg.contains("API key is invalid")); + assert_eq!(msg, "forbidden"); + } + + #[test] + fn format_fail_message_4xx_no_probe_result_falls_through() { + let body = "plain body"; + let msg = format_fail_message(reqwest::StatusCode::NOT_FOUND, body, None); + assert!(!msg.contains("API key is invalid")); + assert_eq!(msg, "plain body"); + } + + #[test] + fn format_fail_message_4xx_authenticated_probe_shows_server_message() { + let body = r#"{"error":{"message":"workspace_not_found"}}"#; + let msg = format_fail_message( + reqwest::StatusCode::NOT_FOUND, + body, + Some(&AuthStatus::Authenticated), + ); + assert_eq!(msg, "workspace_not_found"); + } + + #[test] + fn format_fail_message_403_with_valid_key_shows_real_error() { + let body = r#"{"error":"connection_not_found"}"#; + let msg = format_fail_message( + reqwest::StatusCode::FORBIDDEN, + body, + Some(&AuthStatus::Authenticated), + ); + assert!(!msg.contains("API key is invalid")); + } + + // --- error mapping ------------------------------------------------------- + + #[test] + fn from_sdk_maps_response_error_to_status() { + let err: Error<()> = Error::ResponseError(ResponseContent { + status: reqwest::StatusCode::NOT_FOUND, + content: "missing".to_string(), + entity: None, + }); + match ApiError::from_sdk(err) { + ApiError::Status { status, body } => { + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(body, "missing"); + } + other => panic!("expected Status, got {other:?}"), + } + } + + #[test] + fn none_if_404_swallows_404_only() { + let v404: Result = Err(ApiError::Status { + status: reqwest::StatusCode::NOT_FOUND, + body: String::new(), + }); + assert_eq!(none_if_404(v404).unwrap(), None); + + let ok: Result = Ok(7); + assert_eq!(none_if_404(ok).unwrap(), Some(7)); + + let v500: Result = Err(ApiError::Status { + status: reqwest::StatusCode::INTERNAL_SERVER_ERROR, + body: "boom".into(), + }); + assert!(none_if_404(v500).is_err()); + } + + // --- runtime sanity ------------------------------------------------------ + + #[test] + fn shared_runtime_runs_a_future() { + let n = rt().block_on(async { 21 + 21 }); + assert_eq!(n, 42); + } + + // --- wrapper: workspace-id header + concurrent block_on ------------------ + + const WS_BODY: &str = r#"{"ok":true,"workspaces":[]}"#; + + #[test] + fn list_workspaces_succeeds_with_bearer() { + let mut server = mockito::Server::new(); + let m = server + .mock("GET", "/v1/workspaces") + .match_header("Authorization", "Bearer test-jwt") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(WS_BODY) + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", None); + let resp = api.list_workspaces(None).expect("list should succeed"); + assert!(resp.ok); + m.assert(); + } + + #[test] + fn workspace_id_header_is_installed_on_scoped_calls() { + // Regression for the old api.rs:598 header assertion. `datasets().list` + // carries the X-Workspace-Id api_key; assert it reaches the wire. + let mut server = mockito::Server::new(); + let m = server + .mock("GET", "/v1/datasets") + .match_header("Authorization", "Bearer test-jwt") + .match_header("X-Workspace-Id", "ws-1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"count":0,"datasets":[],"has_more":false,"limit":50,"offset":0}"#) + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1")); + let resp = block(api.client.datasets().list(None, None)).expect("list datasets"); + assert_eq!(resp.count, 0); + m.assert(); + } + + #[test] + fn error_response_maps_to_status() { + let mut server = mockito::Server::new(); + let _m = server + .mock("GET", "/v1/workspaces") + .with_status(500) + .with_body("boom") + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", None); + match api.list_workspaces(None).unwrap_err() { + ApiError::Status { status, body } => { + assert_eq!(status, reqwest::StatusCode::INTERNAL_SERVER_ERROR); + assert!(body.contains("boom")); + } + other => panic!("expected Status, got {other:?}"), + } + } + + #[test] + fn concurrent_block_on_from_rayon_workers() { + use rayon::prelude::*; + + // Mirror indexes.rs: clone the Send+Sync+Clone Api into a rayon + // par_iter and call a wrapper method from many worker threads. The + // shared multi-thread runtime must tolerate concurrent block_on with + // no panic ("cannot start a runtime within a runtime") or deadlock. + let mut server = mockito::Server::new(); + let _m = server + .mock("GET", "/v1/workspaces") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(WS_BODY) + .expect_at_least(8) + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", None); + let results: Vec = (0..8) + .into_par_iter() + .map(|_| { + let api = api.clone(); + api.list_workspaces(None).map(|r| r.ok).unwrap_or(false) + }) + .collect(); + + assert_eq!(results.len(), 8); + assert!(results.iter().all(|ok| *ok), "every worker must succeed"); + } + + #[test] + fn get_json_sends_bearer_workspace_and_query_then_deserializes() { + // The seam's untyped escape hatch (used by results.rs): carry the + // bearer + X-Workspace-Id like every SDK call, append query pairs, hit + // /v1, and deserialize into a caller-owned type that keeps fields + // the generated SDK model would drop. + #[derive(serde::Deserialize)] + struct Probe { + value: u32, + } + + let mut server = mockito::Server::new(); + let m = server + .mock("GET", "/v1/results") + .match_header("Authorization", "Bearer test-jwt") + .match_header("X-Workspace-Id", "ws-1") + .match_query(mockito::Matcher::AllOf(vec![ + mockito::Matcher::UrlEncoded("limit".into(), "10".into()), + mockito::Matcher::UrlEncoded("offset".into(), "5".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"value":42}"#) + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1")); + let probe: Probe = api + .get_json( + "/results", + &[("limit", "10".to_string()), ("offset", "5".to_string())], + ) + .expect("get_json should succeed"); + assert_eq!(probe.value, 42); + m.assert(); + } + + #[test] + fn get_json_maps_error_status() { + #[derive(serde::Deserialize, Debug)] + struct Probe { + #[allow(dead_code)] + value: u32, + } + let mut server = mockito::Server::new(); + let _m = server + .mock("GET", "/v1/results") + .with_status(404) + .with_body("missing") + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", None); + match api.get_json::("/results", &[]).unwrap_err() { + ApiError::Status { status, body } => { + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert!(body.contains("missing")); + } + other => panic!("expected Status, got {other:?}"), + } + } + + #[test] + fn post_raw_sends_bearer_workspace_and_body_then_returns_status() { + // The seam's POST escape hatch (used by connections.rs refresh/create): + // carry the bearer + X-Workspace-Id like every SDK call, send the JSON + // body, and return the raw status + body for caller-side parsing. + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/v1/refresh") + .match_header("Authorization", "Bearer test-jwt") + .match_header("X-Workspace-Id", "ws-1") + .match_body(mockito::Matcher::PartialJsonString( + r#"{"connection_id":"conn_1","data":true}"#.into(), + )) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"rows_synced":7}"#) + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1")); + let body = serde_json::json!({"connection_id": "conn_1", "data": true}); + let (status, text) = api + .post_raw("/refresh", &body) + .expect("post_raw should succeed"); + assert_eq!(status, reqwest::StatusCode::OK); + assert!(text.contains("rows_synced")); + m.assert(); + } + + #[test] + fn post_raw_returns_error_status_without_mapping_to_err() { + // Non-success is returned as Ok((status, body)) so the caller reproduces + // the old `(status, body)` raw-post control flow verbatim. + let mut server = mockito::Server::new(); + let _m = server + .mock("POST", "/v1/refresh") + .with_status(400) + .with_body("bad request") + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", None); + let (status, text) = api + .post_raw("/refresh", &serde_json::json!({})) + .expect("post_raw returns Ok even on non-2xx"); + assert_eq!(status, reqwest::StatusCode::BAD_REQUEST); + assert_eq!(text, "bad request"); + } + + #[test] + fn get_bytes_sends_bearer_workspace_accept_then_returns_body() { + // The seam's binary-body escape hatch (used by query.rs for the Arrow + // result fetch): carry the bearer + X-Workspace-Id + the custom Accept + // like every SDK call, and return the raw status + bytes for CLI-side + // Arrow decoding. + let mut server = mockito::Server::new(); + let m = server + .mock("GET", "/v1/results/res_1") + .match_header("Authorization", "Bearer test-jwt") + .match_header("X-Workspace-Id", "ws-1") + .match_header("Accept", "application/vnd.apache.arrow.stream") + .with_status(200) + .with_header("content-type", "application/vnd.apache.arrow.stream") + .with_body(&[0u8, 1, 2, 3][..]) + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", Some("ws-1")); + let (status, bytes) = api + .get_bytes("/results/res_1", "application/vnd.apache.arrow.stream") + .expect("get_bytes should succeed"); + assert_eq!(status, reqwest::StatusCode::OK); + assert_eq!(bytes, vec![0u8, 1, 2, 3]); + m.assert(); + } + + #[test] + fn get_bytes_returns_non_success_status_with_body() { + // A failed Arrow fetch surfaces the status + body so the caller can + // print the server error (reproducing the old get_bytes control flow, + // which returned (status, bytes) rather than erroring on non-2xx). + let mut server = mockito::Server::new(); + let _m = server + .mock("GET", "/v1/results/missing") + .with_status(404) + .with_body("not found") + .create(); + + let api = Api::test_new(&server.url(), "test-jwt", None); + let (status, bytes) = api + .get_bytes("/results/missing", "application/vnd.apache.arrow.stream") + .expect("get_bytes returns Ok even on non-2xx"); + assert_eq!(status, reqwest::StatusCode::NOT_FOUND); + assert_eq!(String::from_utf8_lossy(&bytes), "not found"); + } + + #[test] + fn clones_share_one_client_arc() { + let api = Api::test_new("http://127.0.0.1:1", "jwt", None); + let clone = api.clone(); + // Cheap clone: both share the same underlying Arc. + assert!(Arc::ptr_eq(&api.client, &clone.client)); + } + + // --- base path: no double /v1 ------------------------------------------- + + #[test] + fn sdk_base_path_strips_one_trailing_v1() { + // The configured api_url carries /v1; base_path must not, so a single + // /v1 is appended downstream. + assert_eq!( + sdk_base_path("https://api.hotdata.dev/v1"), + "https://api.hotdata.dev" + ); + assert_eq!( + sdk_base_path("https://api.hotdata.dev/v1/"), + "https://api.hotdata.dev" + ); + // A host without /v1 is left alone. + assert_eq!( + sdk_base_path("http://127.0.0.1:1234"), + "http://127.0.0.1:1234" + ); + // Only ONE trailing /v1 is stripped. + assert_eq!(sdk_base_path("https://h/v1/v1"), "https://h/v1"); + } + + #[test] + fn calls_hit_single_v1_when_api_url_has_v1_suffix() { + // Regression for the production-breaking double-/v1 bug: DEFAULT_API_URL + // ends in /v1 and the SDK appends its own /v1, so an Api built from a + // /v1-suffixed url must still land on a single /v1 (not /v1/v1/...). + let mut server = mockito::Server::new(); + let m = server + .mock("GET", "/v1/workspaces") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(WS_BODY) + .create(); + + // api_url WITH the /v1 suffix the real profile uses. + let api = Api::test_new(&format!("{}/v1", server.url()), "test-jwt", None); + api.list_workspaces(None) + .expect("call must resolve to a single /v1"); + m.assert(); // before the fix this 404s at /v1/v1/workspaces + } + + // --- database scope header ---------------------------------------------- + + #[test] + fn database_scope_sends_x_database_id_on_raw_calls() { + // Regression: `hotdata query --database X` must scope the request. The + // old ApiClient sent X-Database-Id on every request; the seam must too, + // and the raw /query submit path is where the scope is applied. + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/v1/query") + .match_header("Authorization", "Bearer test-jwt") + .match_header("X-Workspace-Id", "ws-1") + .match_header("X-Database-Id", "db-1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true}"#) + .create(); + + let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), None, Some("db-1")); + let (status, _body) = api + .post_raw("/query", &serde_json::json!({"sql": "select 1"})) + .expect("post_raw should succeed"); + assert_eq!(status, reqwest::StatusCode::OK); + m.assert(); + } + + // --- sandbox session scope headers -------------------------------------- + + #[test] + fn sandbox_scope_sends_session_id() { + // When a sandbox is active the seam scopes the request with X-Session-Id. + let mut server = mockito::Server::new(); + let m = server + .mock("POST", "/v1/query") + .match_header("X-Session-Id", "sb-1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"ok":true}"#) + .create(); + + let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), Some("sb-1"), None); + let (status, _body) = api + .post_raw("/query", &serde_json::json!({"sql": "select 1"})) + .expect("post_raw should succeed"); + assert_eq!(status, reqwest::StatusCode::OK); + m.assert(); + } + + #[test] + fn session_id_header_installed_on_scoped_sdk_calls() { + // Generated SDK ops carry X-Session-Id via the apiKey-header auth block, + // the same mechanism as X-Workspace-Id. Assert it reaches the wire on a + // typed call when a sandbox is active. + let mut server = mockito::Server::new(); + let m = server + .mock("GET", "/v1/datasets") + .match_header("X-Workspace-Id", "ws-1") + .match_header("X-Session-Id", "sb-1") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(r#"{"count":0,"datasets":[],"has_more":false,"limit":50,"offset":0}"#) + .create(); + + let api = Api::test_new_scoped(&server.url(), "test-jwt", Some("ws-1"), Some("sb-1"), None); + let resp = block(api.client.datasets().list(None, None)).expect("list datasets"); + assert_eq!(resp.count, 0); + m.assert(); + } +} diff --git a/src/skill.rs b/src/skill.rs index 0555a97..e5f989c 100644 --- a/src/skill.rs +++ b/src/skill.rs @@ -27,18 +27,12 @@ fn home_dir() -> PathBuf { /// The canonical store location: ~/.hotdata/skills/ fn skill_store_path(skill_name: &str) -> PathBuf { - home_dir() - .join(".hotdata") - .join("skills") - .join(skill_name) + home_dir().join(".hotdata").join("skills").join(skill_name) } /// Canonical agents layer: ~/.agents/skills/ fn agents_skill_path(skill_name: &str) -> PathBuf { - home_dir() - .join(".agents") - .join("skills") - .join(skill_name) + home_dir().join(".agents").join("skills").join(skill_name) } fn agents_lock_path() -> PathBuf { @@ -176,10 +170,7 @@ pub fn maybe_auto_update_after_cli_upgrade() { } clear_skill_auto_update_suppression(); - eprintln!( - "{}", - format!("Agent skills updated to v{current}.").green() - ); + eprintln!("{}", format!("Agent skills updated to v{current}.").green()); } fn is_managed_by_skills_agent() -> bool { @@ -450,10 +441,7 @@ pub fn install_project() { format!("./{root} ({skill_name}):"), rel_link.display().to_string().cyan() ), - Err(e) => eprintln!( - "{}", - format!("./{root} ({skill_name}): failed: {e}").red() - ), + Err(e) => eprintln!("{}", format!("./{root} ({skill_name}): failed: {e}").red()), } } } @@ -579,7 +567,7 @@ pub fn status() { } else { "No".red().to_string() }; - row(&format!("{skill_name}"), &status); + row(skill_name, &status); } } else { row("Installed", &"Yes".green().to_string()); diff --git a/src/tables.rs b/src/tables.rs index ab083d9..7015e09 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -1,35 +1,16 @@ -use crate::api::ApiClient; -use serde::{Deserialize, Serialize}; +use crate::sdk::Api; +use hotdata::models::TableInfo; +use serde::Serialize; -#[derive(Deserialize, Serialize)] +#[derive(Serialize)] struct Column { name: String, data_type: String, nullable: bool, } -#[derive(Deserialize)] -struct Table { - connection: String, - schema: String, - table: String, - synced: bool, - last_sync: Option, - #[serde(default)] - columns: Vec, -} - -impl Table { - fn full_name(&self) -> String { - format!("{}.{}.{}", self.connection, self.schema, self.table) - } -} - -#[derive(Deserialize)] -struct ListResponse { - tables: Vec
, - has_more: bool, - next_cursor: Option, +fn full_name(t: &TableInfo) -> String { + format!("{}.{}.{}", t.connection, t.schema, t.table) } #[derive(Serialize)] @@ -55,38 +36,42 @@ pub fn list( cursor: Option<&str>, format: &str, ) { - let api = ApiClient::new(Some(workspace_id)); + let api = Api::new(Some(workspace_id)); - let mut params: Vec<(&str, Option)> = Vec::new(); - if let Some(id) = connection_id { - params.push(("connection_id", Some(id.to_string()))); - params.push(("include_columns", Some("true".to_string()))); - } - if let Some(s) = schema { - params.push(("schema", Some(s.to_string()))); - } - if let Some(t) = table_filter { - params.push(("table", Some(t.to_string()))); - } - if let Some(l) = limit { - params.push(("limit", Some(l.to_string()))); - } - if let Some(c) = cursor { - params.push(("cursor", Some(c.to_string()))); - } + // The CLI only requests columns when a connection is specified, matching + // the old behavior (include_columns=true iff connection_id is set). + let include_columns = connection_id.map(|_| true); - let body: ListResponse = api.get_with_params("/information_schema", ¶ms); + let body = crate::sdk::block(api.client().information_schema().get( + connection_id, + schema, + table_filter, + include_columns, + limit.map(|l| l as i32), + cursor, + )) + .unwrap_or_else(|e| e.exit()); let has_more = body.has_more; - let next_cursor = body.next_cursor.clone(); + let next_cursor = body.next_cursor.flatten(); if connection_id.is_some() { let out: Vec = body .tables .into_iter() .map(|t| TableWithColumns { - table: t.full_name(), - columns: t.columns, + table: full_name(&t), + columns: t + .columns + .flatten() + .unwrap_or_default() + .into_iter() + .map(|c| Column { + name: c.name, + data_type: c.data_type, + nullable: c.nullable, + }) + .collect(), }) .collect(); match format { @@ -120,9 +105,9 @@ pub fn list( .tables .iter() .map(|t| TableRow { - table: t.full_name(), + table: full_name(t), synced: t.synced, - last_sync: t.last_sync.clone(), + last_sync: t.last_sync.clone().flatten(), }) .collect(); out.sort_by(|a, b| a.table.cmp(&b.table)); diff --git a/src/util.rs b/src/util.rs index 0f422eb..e50ecab 100644 --- a/src/util.rs +++ b/src/util.rs @@ -132,37 +132,6 @@ pub fn send_debug( send_debug_with_redaction(client, builder, body_for_log, &[]) } -/// Like `send_debug` but for binary (non-UTF-8) responses such as Arrow IPC. -/// Logs the request and response status in debug mode; prints the byte count -/// instead of attempting to parse the body as JSON. -pub fn send_debug_bytes( - client: &reqwest::blocking::Client, - builder: reqwest::blocking::RequestBuilder, -) -> reqwest::Result<(reqwest::StatusCode, Vec)> { - let request = builder.build()?; - if is_debug() { - log_request_struct(&request, None); - } - let resp = client.execute(request)?; - let status = resp.status(); - let bytes = resp.bytes()?; - if is_debug() { - use crossterm::style::Stylize; - let status_str = format!( - "<<< {} {}", - status.as_u16(), - status.canonical_reason().unwrap_or("") - ); - if status.is_success() { - eprintln!("{}", status_str.dark_green()); - } else { - eprintln!("{}", status_str.dark_red()); - } - eprintln!("{}", format!("[binary: {} bytes]", bytes.len()).dark_grey()); - } - Ok((status, bytes.to_vec())) -} - /// Like `send_debug` but masks the named JSON keys in the printed /// response body. The returned body string is always unredacted. pub fn send_debug_with_redaction( diff --git a/src/workspace.rs b/src/workspace.rs index f95959a..ee30fe8 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,8 +1,8 @@ -use crate::api::ApiClient; use crate::config; -use serde::{Deserialize, Serialize}; +use crate::sdk::Api; +use serde::Serialize; -#[derive(Deserialize, Serialize)] +#[derive(Serialize)] struct Workspace { public_id: String, name: String, @@ -11,9 +11,24 @@ struct Workspace { provision_status: String, } -#[derive(Deserialize)] -struct ListResponse { - workspaces: Vec, +impl From<&hotdata::models::WorkspaceListItem> for Workspace { + fn from(w: &hotdata::models::WorkspaceListItem) -> Self { + Workspace { + public_id: w.public_id.clone(), + name: w.name.clone(), + active: w.active, + favorite: w.favorite, + provision_status: w.provision_status.clone(), + } + } +} + +fn fetch_workspaces() -> Vec { + let api = Api::new(None); + let body = api + .list_workspaces(None) + .unwrap_or_else(|e| e.exit()); + body.workspaces.iter().map(Workspace::from).collect() } pub fn set(workspace_id: Option<&str>) { @@ -23,9 +38,7 @@ pub fn set(workspace_id: Option<&str>) { eprintln!("error: workspace cannot be changed inside a sandbox"); std::process::exit(1); } - let api = ApiClient::new(None); - let body: ListResponse = api.get("/workspaces"); - let workspaces = body.workspaces; + let workspaces = fetch_workspaces(); let chosen = match workspace_id { Some(id) => match workspaces.iter().find(|w| w.public_id == id) { @@ -96,26 +109,21 @@ pub fn list(format: &str) { .unwrap_or_default() }); - let api = ApiClient::new(None); - let body: ListResponse = api.get("/workspaces"); + let workspaces = fetch_workspaces(); match format { "json" => { - println!( - "{}", - serde_json::to_string_pretty(&body.workspaces).unwrap() - ); + println!("{}", serde_json::to_string_pretty(&workspaces).unwrap()); } "yaml" => { - print!("{}", serde_yaml::to_string(&body.workspaces).unwrap()); + print!("{}", serde_yaml::to_string(&workspaces).unwrap()); } "table" => { - if body.workspaces.is_empty() { + if workspaces.is_empty() { use crossterm::style::Stylize; eprintln!("{}", "No workspaces found.".dark_grey()); } else { - let rows: Vec> = body - .workspaces + let rows: Vec> = workspaces .iter() .map(|w| { let marker = if w.public_id == default_id { "*" } else { "" };