Skip to content

Commit 8546919

Browse files
authored
feat(auth) Add CLI auth session support
1 parent a7f54c7 commit 8546919

8 files changed

Lines changed: 1549 additions & 524 deletions

File tree

src/api.rs

Lines changed: 67 additions & 167 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ pub struct ApiClient {
1414
}
1515

1616
impl ApiClient {
17-
/// Create a new API client. Loads config, validates auth.
18-
/// Pass `workspace_id` for endpoints that require it, or `None` for workspace-less endpoints.
17+
/// Create a new API client. Loads config, pre-flights a JWT session.
18+
/// Pass `workspace_id` for endpoints that require it, or `None` for
19+
/// workspace-less endpoints.
1920
pub fn new(workspace_id: Option<&str>) -> Self {
2021
let profile_config = match config::load("default") {
2122
Ok(c) => c,
@@ -25,17 +26,27 @@ impl ApiClient {
2526
}
2627
};
2728

28-
let api_key = match &profile_config.api_key {
29-
Some(key) if key != "PLACEHOLDER" => key.clone(),
30-
_ => {
31-
eprintln!("error: not authenticated. Run 'hotdata auth login' (or 'hotdata auth') to log in.");
29+
let api_key_fallback = profile_config
30+
.api_key
31+
.as_deref()
32+
.filter(|k| !k.is_empty() && *k != "PLACEHOLDER");
33+
34+
// Pre-flight: return the cached JWT if valid, refresh it if
35+
// close to expiry, or mint a new one from the API key. The
36+
// returned string is a JWT — that's what we send on the wire.
37+
let access_token = match crate::jwt::ensure_access_token(&profile_config, api_key_fallback)
38+
{
39+
Ok(t) => t,
40+
Err(e) => {
41+
eprintln!("{}", format!("error: {e}").red());
42+
eprintln!("Run {} to log in, or pass --api-key.", "hotdata auth".cyan());
3243
std::process::exit(1);
3344
}
3445
};
3546

3647
Self {
3748
client: reqwest::blocking::Client::new(),
38-
api_key,
49+
api_key: access_token,
3950
api_url: profile_config.api_url.to_string(),
4051
workspace_id: workspace_id.map(String::from),
4152
sandbox_id: std::env::var("HOTDATA_SANDBOX").ok().or_else(|| {
@@ -60,29 +71,6 @@ impl ApiClient {
6071
}
6172
}
6273

63-
fn debug_headers(&self) -> Vec<(&str, String)> {
64-
let masked = if self.api_key.len() > 4 {
65-
format!("Bearer ...{}", &self.api_key[self.api_key.len()-4..])
66-
} else {
67-
"Bearer ***".to_string()
68-
};
69-
let mut headers = vec![("Authorization", masked)];
70-
if let Some(ref ws) = self.workspace_id {
71-
headers.push(("X-Workspace-Id", ws.clone()));
72-
}
73-
if let Some(ref sid) = self.sandbox_id {
74-
// Send both headers during the session→sandbox migration window.
75-
headers.push(("X-Session-Id", sid.clone()));
76-
headers.push(("X-Sandbox-Id", sid.clone()));
77-
}
78-
headers
79-
}
80-
81-
fn log_request(&self, method: &str, url: &str, body: Option<&serde_json::Value>) {
82-
let headers = self.debug_headers();
83-
let header_refs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (*k, v.as_str())).collect();
84-
util::debug_request(method, url, &header_refs, body);
85-
}
8674

8775
/// Prints an error for a non-2xx response and exits. On 4xx, first re-probes
8876
/// the API key: if it's actually invalid, a clear re-auth hint is shown
@@ -111,29 +99,25 @@ impl ApiClient {
11199
req
112100
}
113101

114-
/// GET request with query parameters, returns parsed response.
115-
/// Parameters with `None` values are omitted.
116-
pub fn get_with_params<T: DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> T {
117-
let filtered: Vec<(&str, &String)> = params.iter()
118-
.filter_map(|(k, v)| v.as_ref().map(|val| (*k, val)))
119-
.collect();
120-
let url = format!("{}{path}", self.api_url);
121-
self.log_request("GET", &url, None);
122-
123-
let resp = match self.build_request(reqwest::Method::GET, &url).query(&filtered).send() {
124-
Ok(r) => r,
102+
/// Send via `util::send_debug` and unwrap connection errors with the
103+
/// CLI's standard "error connecting" exit. All public HTTP methods
104+
/// route through here so debug logging is uniform.
105+
fn send(
106+
&self,
107+
builder: reqwest::blocking::RequestBuilder,
108+
body_for_log: Option<&serde_json::Value>,
109+
) -> (reqwest::StatusCode, String) {
110+
match util::send_debug(&self.client, builder, body_for_log) {
111+
Ok(pair) => pair,
125112
Err(e) => {
126113
eprintln!("error connecting to API: {e}");
127114
std::process::exit(1);
128115
}
129-
};
130-
131-
let (status, body) = util::debug_response(resp);
132-
if !status.is_success() {
133-
self.fail_response(status, body);
134116
}
117+
}
135118

136-
match serde_json::from_str(&body) {
119+
fn parse_json<T: DeserializeOwned>(body: &str) -> T {
120+
match serde_json::from_str(body) {
137121
Ok(v) => v,
138122
Err(e) => {
139123
eprintln!("error parsing response: {e}");
@@ -142,159 +126,83 @@ impl ApiClient {
142126
}
143127
}
144128

145-
/// GET request, returns parsed response.
146-
pub fn get<T: DeserializeOwned>(&self, path: &str) -> T {
129+
/// GET request with query parameters, returns parsed response.
130+
/// Parameters with `None` values are omitted.
131+
pub fn get_with_params<T: DeserializeOwned>(&self, path: &str, params: &[(&str, Option<String>)]) -> T {
132+
let filtered: Vec<(&str, &String)> = params.iter()
133+
.filter_map(|(k, v)| v.as_ref().map(|val| (*k, val)))
134+
.collect();
147135
let url = format!("{}{path}", self.api_url);
148-
self.log_request("GET", &url, None);
149-
150-
let resp = match self.build_request(reqwest::Method::GET, &url).send() {
151-
Ok(r) => r,
152-
Err(e) => {
153-
eprintln!("error connecting to API: {e}");
154-
std::process::exit(1);
155-
}
156-
};
157-
158-
let (status, body) = util::debug_response(resp);
136+
let req = self.build_request(reqwest::Method::GET, &url).query(&filtered);
137+
let (status, body) = self.send(req, None);
159138
if !status.is_success() {
160139
self.fail_response(status, body);
161140
}
141+
Self::parse_json(&body)
142+
}
162143

163-
match serde_json::from_str(&body) {
164-
Ok(v) => v,
165-
Err(e) => {
166-
eprintln!("error parsing response: {e}");
167-
std::process::exit(1);
168-
}
144+
/// GET request, returns parsed response.
145+
pub fn get<T: DeserializeOwned>(&self, path: &str) -> T {
146+
let url = format!("{}{path}", self.api_url);
147+
let req = self.build_request(reqwest::Method::GET, &url);
148+
let (status, body) = self.send(req, None);
149+
if !status.is_success() {
150+
self.fail_response(status, body);
169151
}
152+
Self::parse_json(&body)
170153
}
171154

172155
/// GET request; returns `None` on HTTP 404. Other status codes use the same handling as
173156
/// [`Self::get`]. Used when probing many paths where a missing resource is normal.
174157
pub fn get_none_if_not_found<T: DeserializeOwned>(&self, path: &str) -> Option<T> {
175158
let url = format!("{}{path}", self.api_url);
176-
self.log_request("GET", &url, None);
177-
178-
let resp = match self.build_request(reqwest::Method::GET, &url).send() {
179-
Ok(r) => r,
180-
Err(e) => {
181-
eprintln!("error connecting to API: {e}");
182-
std::process::exit(1);
183-
}
184-
};
185-
186-
let (status, body) = util::debug_response(resp);
159+
let req = self.build_request(reqwest::Method::GET, &url);
160+
let (status, body) = self.send(req, None);
187161
if status == reqwest::StatusCode::NOT_FOUND {
188162
return None;
189163
}
190164
if !status.is_success() {
191165
self.fail_response(status, body);
192166
}
193-
194-
match serde_json::from_str(&body) {
195-
Ok(v) => Some(v),
196-
Err(e) => {
197-
eprintln!("error parsing response: {e}");
198-
std::process::exit(1);
199-
}
200-
}
167+
Some(Self::parse_json(&body))
201168
}
202169

203170
/// POST request with JSON body, returns parsed response.
204171
pub fn post<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> T {
205172
let url = format!("{}{path}", self.api_url);
206-
self.log_request("POST", &url, Some(body));
207-
208-
let resp = match self.build_request(reqwest::Method::POST, &url)
209-
.json(body)
210-
.send()
211-
{
212-
Ok(r) => r,
213-
Err(e) => {
214-
eprintln!("error connecting to API: {e}");
215-
std::process::exit(1);
216-
}
217-
};
218-
219-
let (status, resp_body) = util::debug_response(resp);
173+
let req = self.build_request(reqwest::Method::POST, &url).json(body);
174+
let (status, resp_body) = self.send(req, Some(body));
220175
if !status.is_success() {
221176
self.fail_response(status, resp_body);
222177
}
223-
224-
match serde_json::from_str(&resp_body) {
225-
Ok(v) => v,
226-
Err(e) => {
227-
eprintln!("error parsing response: {e}");
228-
std::process::exit(1);
229-
}
230-
}
178+
Self::parse_json(&resp_body)
231179
}
232180

233181
/// GET request, exits only on connection error, returns raw (status, body).
234182
/// Use for best-effort endpoints (e.g. health checks) where the caller wants
235183
/// to handle non-2xx responses gracefully instead of aborting.
236184
pub fn get_raw(&self, path: &str) -> (reqwest::StatusCode, String) {
237185
let url = format!("{}{path}", self.api_url);
238-
self.log_request("GET", &url, None);
239-
240-
let resp = match self.build_request(reqwest::Method::GET, &url).send() {
241-
Ok(r) => r,
242-
Err(e) => {
243-
eprintln!("error connecting to API: {e}");
244-
std::process::exit(1);
245-
}
246-
};
247-
248-
util::debug_response(resp)
186+
let req = self.build_request(reqwest::Method::GET, &url);
187+
self.send(req, None)
249188
}
250189

251190
/// POST request with JSON body, exits on error, returns raw (status, body).
252191
pub fn post_raw(&self, path: &str, body: &serde_json::Value) -> (reqwest::StatusCode, String) {
253192
let url = format!("{}{path}", self.api_url);
254-
self.log_request("POST", &url, Some(body));
255-
256-
let resp = match self.build_request(reqwest::Method::POST, &url)
257-
.json(body)
258-
.send()
259-
{
260-
Ok(r) => r,
261-
Err(e) => {
262-
eprintln!("error connecting to API: {e}");
263-
std::process::exit(1);
264-
}
265-
};
266-
267-
util::debug_response(resp)
193+
let req = self.build_request(reqwest::Method::POST, &url).json(body);
194+
self.send(req, Some(body))
268195
}
269196

270197
/// PATCH request with JSON body, returns parsed response.
271198
pub fn patch<T: DeserializeOwned>(&self, path: &str, body: &serde_json::Value) -> T {
272199
let url = format!("{}{path}", self.api_url);
273-
self.log_request("PATCH", &url, Some(body));
274-
275-
let resp = match self.build_request(reqwest::Method::PATCH, &url)
276-
.json(body)
277-
.send()
278-
{
279-
Ok(r) => r,
280-
Err(e) => {
281-
eprintln!("error connecting to API: {e}");
282-
std::process::exit(1);
283-
}
284-
};
285-
286-
let (status, resp_body) = util::debug_response(resp);
200+
let req = self.build_request(reqwest::Method::PATCH, &url).json(body);
201+
let (status, resp_body) = self.send(req, Some(body));
287202
if !status.is_success() {
288203
self.fail_response(status, resp_body);
289204
}
290-
291-
match serde_json::from_str(&resp_body) {
292-
Ok(v) => v,
293-
Err(e) => {
294-
eprintln!("error parsing response: {e}");
295-
std::process::exit(1);
296-
}
297-
}
205+
Self::parse_json(&resp_body)
298206
}
299207

300208
/// POST with a custom request body (for file uploads). Returns raw status and body.
@@ -306,24 +214,16 @@ impl ApiClient {
306214
content_length: Option<u64>,
307215
) -> (reqwest::StatusCode, String) {
308216
let url = format!("{}{path}", self.api_url);
309-
self.log_request("POST", &url, None);
310-
311217
let mut req = self.build_request(reqwest::Method::POST, &url)
312218
.header("Content-Type", content_type);
313-
314219
if let Some(len) = content_length {
315220
req = req.header("Content-Length", len);
316221
}
317-
318-
let resp = match req.body(reqwest::blocking::Body::new(reader)).send() {
319-
Ok(r) => r,
320-
Err(e) => {
321-
eprintln!("error connecting to API: {e}");
322-
std::process::exit(1);
323-
}
324-
};
325-
326-
util::debug_response(resp)
222+
let req = req.body(reqwest::blocking::Body::new(reader));
223+
// Body is an opaque stream — nothing meaningful to print under
224+
// --debug, so pass `None`. Headers (including the masked
225+
// Authorization) still log.
226+
self.send(req, None)
327227
}
328228

329229
}

0 commit comments

Comments
 (0)