1use serde::Deserialize;
14
15use crate::{config::CachedToken, errors::SdkError};
16
17use super::AdminApiClient;
18
19#[cfg_attr(feature = "python", pyo3_stub_gen::derive::gen_stub_pyclass)]
23#[cfg_attr(feature = "python", pyo3::pyclass(get_all))]
24#[cfg_attr(feature = "node", napi_derive::napi(object))]
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct ToolingAccessStatus {
27 pub enabled: bool,
28 pub endpoint_url: Option<String>,
29 pub enabled_at: Option<String>,
30 pub endpoint_id: Option<String>,
34}
35
36#[derive(Deserialize)]
39struct Envelope<T> {
40 data: Option<T>,
41 error: Option<String>,
42}
43
44#[derive(Deserialize)]
45struct StatusData {
46 enabled: bool,
47 #[serde(default)]
48 endpoint_url: Option<String>,
49 #[serde(default)]
50 enabled_at: Option<String>,
51 #[serde(default, deserialize_with = "deserialize_optional_id")]
54 endpoint_id: Option<String>,
55}
56
57#[derive(Deserialize)]
58struct TokenData {
59 endpoint_url: String,
60 token: String,
61 expires_at: String,
62}
63
64impl AdminApiClient {
65 pub async fn tooling_access_status(&self) -> Result<ToolingAccessStatus, SdkError> {
68 let url = self.config.admin().base_url.join("tooling-access")?;
69 let resp = self
70 .config
71 .http_client()
72 .get(url)
73 .send()
74 .await
75 .map_err(SdkError::Http)?;
76 Self::parse_status(resp).await
77 }
78
79 pub async fn enable_tooling_access(&self) -> Result<ToolingAccessStatus, SdkError> {
83 self.set_tooling_access_enabled(true).await
84 }
85
86 pub async fn disable_tooling_access(&self) -> Result<ToolingAccessStatus, SdkError> {
88 self.set_tooling_access_enabled(false).await
89 }
90
91 async fn set_tooling_access_enabled(
92 &self,
93 enabled: bool,
94 ) -> Result<ToolingAccessStatus, SdkError> {
95 let url = self.config.admin().base_url.join("tooling-access")?;
96 let resp = self
97 .config
98 .http_client()
99 .patch(url)
100 .json(&serde_json::json!({ "enabled": enabled }))
101 .send()
102 .await
103 .map_err(SdkError::Http)?;
104 Self::parse_status(resp).await
105 }
106
107 pub async fn mint_tooling_token(&self) -> Result<CachedToken, SdkError> {
112 let url = self.config.admin().base_url.join("tooling-access/token")?;
113 let resp = self
114 .config
115 .http_client()
116 .post(url)
117 .send()
118 .await
119 .map_err(SdkError::Http)?;
120
121 let status = resp.status();
122 let body = resp.text().await.map_err(SdkError::Http)?;
123 if !status.is_success() {
124 return Err(SdkError::Api { status, body });
125 }
126 let env: Envelope<TokenData> =
127 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?;
128 let data = env.data.ok_or_else(|| SdkError::Api {
129 status,
130 body: env
131 .error
132 .unwrap_or_else(|| "missing token data".to_string()),
133 })?;
134 let exp_unix = parse_rfc3339_to_unix(&data.expires_at)?;
135 Ok(CachedToken {
136 endpoint_url: data.endpoint_url,
137 token: data.token,
138 exp_unix,
139 })
140 }
141
142 async fn parse_status(resp: reqwest::Response) -> Result<ToolingAccessStatus, SdkError> {
143 let status = resp.status();
144 let body = resp.text().await.map_err(SdkError::Http)?;
145 if !status.is_success() {
146 return Err(SdkError::Api { status, body });
147 }
148 let env: Envelope<StatusData> =
149 serde_json::from_str(&body).map_err(|source| SdkError::Decode { source, body })?;
150 let data = env.data.ok_or_else(|| SdkError::Api {
151 status,
152 body: env
153 .error
154 .unwrap_or_else(|| "missing status data".to_string()),
155 })?;
156 Ok(ToolingAccessStatus {
157 enabled: data.enabled,
158 endpoint_url: data.endpoint_url,
159 enabled_at: data.enabled_at,
160 endpoint_id: data.endpoint_id,
161 })
162 }
163}
164
165fn deserialize_optional_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
168where
169 D: serde::Deserializer<'de>,
170{
171 use serde::Deserialize;
172 #[derive(Deserialize)]
173 #[serde(untagged)]
174 enum StringOrNum {
175 Str(String),
176 Num(i64),
177 }
178 let opt = Option::<StringOrNum>::deserialize(deserializer)?;
179 Ok(opt.map(|v| match v {
180 StringOrNum::Str(s) => s,
181 StringOrNum::Num(n) => n.to_string(),
182 }))
183}
184
185pub(crate) fn parse_rfc3339_to_unix(s: &str) -> Result<i64, SdkError> {
189 let bad = || SdkError::Decode {
190 source: serde::de::Error::custom("invalid expires_at timestamp"),
193 body: s.to_string(),
194 };
195
196 let (date, rest) = s.split_once('T').ok_or_else(bad)?;
197 let mut date_parts = date.split('-');
198 let y: i64 = date_parts
199 .next()
200 .and_then(|v| v.parse().ok())
201 .ok_or_else(bad)?;
202 let mo: i64 = date_parts
203 .next()
204 .and_then(|v| v.parse().ok())
205 .ok_or_else(bad)?;
206 let d: i64 = date_parts
207 .next()
208 .and_then(|v| v.parse().ok())
209 .ok_or_else(bad)?;
210
211 let (time_part, offset_secs) = if let Some(t) = rest.strip_suffix('Z') {
213 (t, 0i64)
214 } else if let Some(idx) = rest.rfind(['+', '-']) {
215 let (t, tz) = rest.split_at(idx);
216 let sign = if tz.starts_with('-') { -1 } else { 1 };
217 let tz = &tz[1..];
218 let (oh, om) = tz.split_once(':').ok_or_else(bad)?;
219 let oh: i64 = oh.parse().map_err(|_| bad())?;
220 let om: i64 = om.parse().map_err(|_| bad())?;
221 (t, sign * (oh * 3600 + om * 60))
222 } else {
223 (rest, 0i64)
224 };
225
226 let time_main = time_part.split('.').next().unwrap_or(time_part);
228 let mut tparts = time_main.split(':');
229 let hh: i64 = tparts.next().and_then(|v| v.parse().ok()).ok_or_else(bad)?;
230 let mm: i64 = tparts.next().and_then(|v| v.parse().ok()).ok_or_else(bad)?;
231 let ss: i64 = tparts.next().and_then(|v| v.parse().ok()).unwrap_or(0);
232
233 let days = days_from_civil(y, mo, d);
234 let secs = days * 86_400 + hh * 3600 + mm * 60 + ss - offset_secs;
235 Ok(secs)
236}
237
238fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
241 let y = if m <= 2 { y - 1 } else { y };
242 let era = if y >= 0 { y } else { y - 399 } / 400;
243 let yoe = y - era * 400;
244 let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
245 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
246 era * 146_097 + doe - 719_468
247}
248
249#[cfg(test)]
250#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
251mod tests {
252 use super::*;
253 use crate::config::{AdminConfig, SdkFullConfig};
254 use crate::QuicknodeSdk;
255 use wiremock::matchers::{method, path};
256 use wiremock::{Mock, MockServer, ResponseTemplate};
257
258 fn sdk_for(base: &str) -> QuicknodeSdk {
259 let mut cfg = SdkFullConfig::from_api_key("test-key".to_string());
260 cfg.admin = Some(AdminConfig {
261 base_url: Some(format!("{base}/")),
262 });
263 QuicknodeSdk::new(&cfg).unwrap()
264 }
265
266 #[test]
267 fn parses_utc_z_timestamp() {
268 let got = parse_rfc3339_to_unix("2026-06-23T20:40:00.000Z").unwrap();
270 assert_eq!(got, 1_782_247_200, "got {got}");
271 }
272
273 #[test]
274 fn epoch_round_trips() {
275 assert_eq!(parse_rfc3339_to_unix("1970-01-01T00:00:00Z").unwrap(), 0);
276 assert_eq!(parse_rfc3339_to_unix("1970-01-01T00:00:01Z").unwrap(), 1);
277 }
278
279 #[test]
280 fn applies_positive_offset() {
281 assert_eq!(
283 parse_rfc3339_to_unix("1970-01-01T00:00:00+01:00").unwrap(),
284 -3600
285 );
286 }
287
288 #[test]
289 fn rejects_garbage_timestamp() {
290 assert!(matches!(
291 parse_rfc3339_to_unix("not-a-date"),
292 Err(SdkError::Decode { .. })
293 ));
294 }
295
296 #[tokio::test]
297 async fn status_happy_path() {
298 let server = MockServer::start().await;
299 Mock::given(method("GET"))
300 .and(path("/tooling-access"))
301 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
302 "data": {
303 "enabled": true,
304 "endpoint_id": 3,
307 "endpoint_url": "https://tooling-access-abc123.quiknode.pro",
308 "enabled_at": "2026-06-23T20:30:00.000Z"
309 },
310 "error": null
311 })))
312 .mount(&server)
313 .await;
314
315 let sdk = sdk_for(&server.uri());
316 let status = sdk.admin.tooling_access_status().await.unwrap();
317 assert!(status.enabled);
318 assert_eq!(
319 status.endpoint_url.as_deref(),
320 Some("https://tooling-access-abc123.quiknode.pro")
321 );
322 assert_eq!(status.endpoint_id.as_deref(), Some("3"));
323 }
324
325 #[tokio::test]
326 async fn enable_returns_status() {
327 let server = MockServer::start().await;
328 Mock::given(method("PATCH"))
329 .and(path("/tooling-access"))
330 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
331 "data": { "enabled": true, "endpoint_url": "https://x.quiknode.pro" },
332 "error": null
333 })))
334 .mount(&server)
335 .await;
336
337 let sdk = sdk_for(&server.uri());
338 let status = sdk.admin.enable_tooling_access().await.unwrap();
339 assert!(status.enabled);
340 }
341
342 #[tokio::test]
343 async fn mint_token_happy_path() {
344 let server = MockServer::start().await;
345 Mock::given(method("POST"))
346 .and(path("/tooling-access/token"))
347 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
348 "data": {
349 "endpoint_url": "https://tooling-access-abc123.quiknode.pro",
350 "token": "header.payload.sig",
351 "expires_at": "2026-06-23T20:40:00.000Z"
352 },
353 "error": null
354 })))
355 .mount(&server)
356 .await;
357
358 let sdk = sdk_for(&server.uri());
359 let tok = sdk.admin.mint_tooling_token().await.unwrap();
360 assert_eq!(tok.token, "header.payload.sig");
361 assert_eq!(tok.exp_unix, 1_782_247_200);
362 }
363
364 #[tokio::test]
365 async fn mint_token_not_enabled_is_api_error() {
366 let server = MockServer::start().await;
367 Mock::given(method("POST"))
368 .and(path("/tooling-access/token"))
369 .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
370 "data": null,
371 "error": "Tooling access is not enabled. Enable it first."
372 })))
373 .mount(&server)
374 .await;
375
376 let sdk = sdk_for(&server.uri());
377 let err = sdk.admin.mint_tooling_token().await.unwrap_err();
378 match err {
379 SdkError::Api { status, body } => {
380 assert_eq!(status.as_u16(), 400);
381 assert!(body.contains("not enabled"), "body: {body}");
382 }
383 other => panic!("expected Api error, got {other:?}"),
384 }
385 }
386}