Skip to content

Commit 4be2b92

Browse files
committed
fix: better URL handling in client
1 parent ee4f537 commit 4be2b92

4 files changed

Lines changed: 93 additions & 234 deletions

File tree

crates/charon/src/obolapi/client.rs

Lines changed: 80 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,11 @@ use crate::obolapi::error::{Error, Result};
1414
/// Default HTTP request timeout if not specified.
1515
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
1616

17-
/// Launchpad URL path format string for a given cluster lock hash.
18-
const LAUNCHPAD_RETURN_PATH_FMT: &str = "/lock/0x{}/launchpad";
19-
2017
/// REST client for Obol API requests.
2118
#[derive(Debug, Clone)]
2219
pub struct Client {
2320
/// Base Obol API URL.
24-
base_url: String,
21+
base_url: Url,
2522

2623
/// HTTP request timeout.
2724
_req_timeout: Duration,
@@ -53,29 +50,30 @@ impl ClientOptions {
5350
impl Client {
5451
/// Creates a new Obol API client.
5552
pub fn new(url_str: &str, options: ClientOptions) -> Result<Self> {
56-
Url::parse(url_str)?;
57-
5853
let req_timeout = options.timeout.unwrap_or(DEFAULT_TIMEOUT);
5954

6055
let http_client = reqwest::Client::builder().timeout(req_timeout).build()?;
6156

57+
// Ensure base_url ends with a trailing slash for proper URL joining
58+
let normalized_url = if url_str.ends_with('/') {
59+
url_str.to_string()
60+
} else {
61+
format!("{}/", url_str)
62+
};
63+
let base_url = Url::parse(&normalized_url)?;
64+
6265
Ok(Self {
63-
base_url: url_str.to_string(),
66+
base_url,
6467
_req_timeout: req_timeout,
6568
http_client,
6669
})
6770
}
6871

69-
/// Returns the base URL from the baseURL stored in client.
70-
pub(crate) fn url(&self) -> Url {
71-
Url::parse(&self.base_url).expect("parse Obol API URL, this should never happen")
72-
}
73-
7472
/// Returns the Launchpad cluster dashboard page for a
7573
/// given lock, on the given Obol API client.
76-
pub fn launchpad_url_for_lock(&self, lock: &Lock) -> String {
77-
let url = self.build_url(&launchpad_url_path(lock));
78-
url.to_string()
74+
pub fn launchpad_url_for_lock(&self, lock: &Lock) -> Result<String> {
75+
let url = self.build_url(&launchpad_url_path(lock))?;
76+
Ok(url.to_string())
7977
}
8078

8179
/// Returns a reference to the HTTP client for making requests.
@@ -84,18 +82,10 @@ impl Client {
8482
}
8583

8684
/// Builds a URL by safely appending a path to the base URL.
87-
pub(crate) fn build_url(&self, path: &str) -> Url {
88-
let mut base = self.url();
89-
let current = base.path().trim_end_matches('/');
90-
let new_path = path.trim_start_matches('/');
91-
92-
if current.is_empty() {
93-
base.set_path(&format!("/{}", new_path));
94-
} else {
95-
base.set_path(&format!("{}/{}", current, new_path));
96-
}
97-
98-
base
85+
/// Strip leading '/' from path for proper URL joining
86+
pub(crate) fn build_url(&self, path: &str) -> Result<Url> {
87+
let path = path.trim_start_matches('/');
88+
Ok(self.base_url.join(path)?)
9989
}
10090

10191
/// Makes an HTTP POST request.
@@ -209,7 +199,7 @@ impl Client {
209199

210200
fn launchpad_url_path(lock: &Lock) -> String {
211201
let hash_hex = hex::encode(&lock.lock_hash).to_uppercase();
212-
LAUNCHPAD_RETURN_PATH_FMT.replace("{}", &hash_hex)
202+
format!("/lock/0x{}/launchpad", &hash_hex)
213203
}
214204

215205
#[cfg(test)]
@@ -247,73 +237,89 @@ mod tests {
247237

248238
#[test]
249239
fn test_new_client_valid_url() {
250-
let client = Client::new("https://api.obol.tech", ClientOptions::default());
251-
assert!(client.is_ok());
240+
assert!(Client::new("https://api.obol.tech", ClientOptions::default()).is_ok());
252241
}
253242

254243
#[test]
255244
fn test_new_client_invalid_url() {
256-
let client = Client::new("not-a-url", ClientOptions::default());
257-
assert!(client.is_err());
245+
assert!(Client::new("not-a-url", ClientOptions::default()).is_err());
258246
}
259247

260248
#[test]
261-
fn test_launchpad_url_path() {
262-
let lock = test_lock_with_hash(vec![0x12, 0x34, 0xab, 0xcd]);
263-
let path = launchpad_url_path(&lock);
264-
assert_eq!(path, "/lock/0x1234ABCD/launchpad");
265-
}
249+
fn test_base_url_normalization() {
250+
let c1 = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
251+
assert_eq!(c1.base_url.as_str(), "https://api.obol.tech/");
266252

267-
#[test]
268-
fn test_launchpad_url_for_lock() {
269-
let client = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
270-
let lock = test_lock_with_hash(vec![0x12, 0x34, 0xab, 0xcd]);
271-
let url = client.launchpad_url_for_lock(&lock);
272-
assert_eq!(url, "https://api.obol.tech/lock/0x1234ABCD/launchpad");
273-
}
253+
let c2 = Client::new("https://api.obol.tech/", ClientOptions::default()).unwrap();
254+
assert_eq!(c2.base_url.as_str(), "https://api.obol.tech/");
274255

275-
#[test]
276-
fn test_build_url_with_root_base() {
277-
// Base path is "/" (root)
278-
let client = Client::new("https://api.obol.tech/", ClientOptions::default()).unwrap();
256+
let c3 = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
257+
assert_eq!(c3.base_url.as_str(), "https://api.obol.tech/v1/");
279258

280-
let url = client.build_url("/definition");
281-
assert_eq!(url.path(), "/definition");
259+
let c4 = Client::new("https://api.obol.tech/v1/", ClientOptions::default()).unwrap();
260+
assert_eq!(c4.base_url.as_str(), "https://api.obol.tech/v1/");
261+
}
282262

283-
let url = client.build_url("definition");
284-
assert_eq!(url.path(), "/definition");
263+
#[test]
264+
fn test_build_url_root_base() {
265+
let client = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
266+
assert_eq!(
267+
client.build_url("definition").unwrap().as_str(),
268+
"https://api.obol.tech/definition"
269+
);
270+
assert_eq!(
271+
client.build_url("/definition").unwrap().as_str(),
272+
"https://api.obol.tech/definition"
273+
);
274+
assert_eq!(
275+
client
276+
.build_url("exp/partial_exits/0xabc")
277+
.unwrap()
278+
.as_str(),
279+
"https://api.obol.tech/exp/partial_exits/0xabc"
280+
);
285281
}
286282

287283
#[test]
288-
fn test_build_url_with_non_root_base() {
289-
// Base path is "/v1"
284+
fn test_build_url_versioned_base() {
290285
let client = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
291-
292-
let url = client.build_url("/definition");
293-
assert_eq!(url.path(), "/v1/definition");
294-
295-
let url = client.build_url("definition");
296-
assert_eq!(url.path(), "/v1/definition");
286+
assert_eq!(
287+
client.build_url("definition").unwrap().as_str(),
288+
"https://api.obol.tech/v1/definition"
289+
);
290+
assert_eq!(
291+
client.build_url("/lock").unwrap().as_str(),
292+
"https://api.obol.tech/v1/lock"
293+
);
294+
assert_eq!(
295+
client
296+
.build_url("exp/exit/0xlock/5/0xkey")
297+
.unwrap()
298+
.as_str(),
299+
"https://api.obol.tech/v1/exp/exit/0xlock/5/0xkey"
300+
);
297301
}
298302

299303
#[test]
300-
fn test_build_url_with_trailing_slash() {
301-
// Base path has trailing slash
302-
let client = Client::new("https://api.obol.tech/api/", ClientOptions::default()).unwrap();
303-
304-
let url = client.build_url("/definition");
305-
assert_eq!(url.path(), "/api/definition");
306-
307-
let url = client.build_url("definition");
308-
assert_eq!(url.path(), "/api/definition");
304+
fn test_launchpad_url_path() {
305+
let lock = test_lock_with_hash(vec![0x12, 0x34, 0xab, 0xcd]);
306+
assert_eq!(launchpad_url_path(&lock), "/lock/0x1234ABCD/launchpad");
309307
}
310308

311309
#[test]
312-
fn test_build_url_empty_base() {
313-
// Edge case: empty path (should be treated as root)
314-
let client = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
310+
fn test_launchpad_url_for_lock() {
311+
let lock = test_lock_with_hash(vec![0x12, 0x34, 0xab, 0xcd]);
315312

316-
let url = client.build_url("/definition");
317-
assert_eq!(url.path(), "/definition");
313+
let c1 = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
314+
assert_eq!(
315+
c1.launchpad_url_for_lock(&lock).unwrap(),
316+
"https://api.obol.tech/lock/0x1234ABCD/launchpad"
317+
);
318+
319+
let c2 = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
320+
assert_eq!(
321+
c2.launchpad_url_for_lock(&lock).unwrap(),
322+
"https://api.obol.tech/v1/lock/0x1234ABCD/launchpad"
323+
);
318324
}
319325
}

crates/charon/src/obolapi/exit.rs

Lines changed: 9 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -243,15 +243,6 @@ const SSZ_MAX_EXITS: usize = 65536;
243243
const SSZ_LEN_PUB_KEY: usize = 48;
244244
const SSZ_LEN_BLS_SIG: usize = 96;
245245

246-
const LOCK_HASH_PATH: &str = "{lock_hash}";
247-
const VAL_PUBKEY_PATH: &str = "{validator_pubkey}";
248-
const SHARE_INDEX_PATH: &str = "{share_index}";
249-
250-
const SUBMIT_PARTIAL_EXIT_TMPL: &str = "/exp/partial_exits/{lock_hash}";
251-
const DELETE_PARTIAL_EXIT_TMPL: &str =
252-
"/exp/partial_exits/{lock_hash}/{share_index}/{validator_pubkey}";
253-
const FETCH_FULL_EXIT_TMPL: &str = "/exp/exit/{lock_hash}/{share_index}/{validator_pubkey}";
254-
255246
impl Client {
256247
/// Posts the set of msg's to the Obol API, for a given lock hash.
257248
/// It respects the timeout specified in the Client instance.
@@ -265,7 +256,7 @@ impl Client {
265256
let lock_hash_str = to_0x(lock_hash);
266257
let path = submit_partial_exit_url(&lock_hash_str);
267258

268-
let url = self.build_url(&path);
259+
let url = self.build_url(&path)?;
269260

270261
// Sort by validator index ascending
271262
exit_blobs.sort_by_key(|blob| {
@@ -311,7 +302,7 @@ impl Client {
311302

312303
let path = fetch_full_exit_url(val_pubkey, &to_0x(lock_hash), share_index);
313304

314-
let url = self.build_url(&path);
305+
let url = self.build_url(&path)?;
315306

316307
// Create authentication blob
317308
let exit_auth_data = FullExitAuthBlob {
@@ -391,7 +382,7 @@ impl Client {
391382

392383
let path = delete_partial_exit_url(val_pubkey, &to_0x(lock_hash), share_index);
393384

394-
let url = self.build_url(&path);
385+
let url = self.build_url(&path)?;
395386

396387
let exit_auth_data = FullExitAuthBlob {
397388
lock_hash: lock_hash.to_vec(),
@@ -416,23 +407,20 @@ impl Client {
416407

417408
/// Returns the partial exit Obol API URL for a given lock hash.
418409
fn submit_partial_exit_url(lock_hash: &str) -> String {
419-
SUBMIT_PARTIAL_EXIT_TMPL.replace(LOCK_HASH_PATH, lock_hash)
410+
format!("/exp/partial_exits/{}", lock_hash)
420411
}
421412

422413
/// Returns the delete partial exit Obol API URL.
423414
fn delete_partial_exit_url(val_pubkey: &str, lock_hash: &str, share_index: u64) -> String {
424-
DELETE_PARTIAL_EXIT_TMPL
425-
.replace(VAL_PUBKEY_PATH, val_pubkey)
426-
.replace(LOCK_HASH_PATH, lock_hash)
427-
.replace(SHARE_INDEX_PATH, &share_index.to_string())
415+
format!(
416+
"/exp/partial_exits/{}/{}/{}",
417+
lock_hash, share_index, val_pubkey
418+
)
428419
}
429420

430421
/// Returns the full exit Obol API URL.
431422
fn fetch_full_exit_url(val_pubkey: &str, lock_hash: &str, share_index: u64) -> String {
432-
FETCH_FULL_EXIT_TMPL
433-
.replace(VAL_PUBKEY_PATH, val_pubkey)
434-
.replace(LOCK_HASH_PATH, lock_hash)
435-
.replace(SHARE_INDEX_PATH, &share_index.to_string())
423+
format!("/exp/exit/{}/{}/{}", lock_hash, share_index, val_pubkey)
436424
}
437425

438426
fn put_bytes_n(hh: &mut Hasher, bytes: &[u8], expected_len: usize) -> Result<()> {
@@ -451,7 +439,6 @@ fn put_bytes_n(hh: &mut Hasher, bytes: &[u8], expected_len: usize) -> Result<()>
451439
#[cfg(test)]
452440
mod tests {
453441
use super::*;
454-
use crate::obolapi::ClientOptions;
455442

456443
#[test]
457444
fn test_submit_partial_exit_url() {
@@ -471,72 +458,6 @@ mod tests {
471458
assert_eq!(url, "/exp/exit/0xlockhash/5/0xpubkey");
472459
}
473460

474-
#[test]
475-
fn test_build_submit_partial_exit_url_root_base() {
476-
let client = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
477-
let path = submit_partial_exit_url("0xabcd1234");
478-
let url = client.build_url(&path);
479-
assert_eq!(
480-
url.as_str(),
481-
"https://api.obol.tech/exp/partial_exits/0xabcd1234"
482-
);
483-
}
484-
485-
#[test]
486-
fn test_build_submit_partial_exit_url_v1_base() {
487-
let client = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
488-
let path = submit_partial_exit_url("0xabcd1234");
489-
let url = client.build_url(&path);
490-
assert_eq!(
491-
url.as_str(),
492-
"https://api.obol.tech/v1/exp/partial_exits/0xabcd1234"
493-
);
494-
}
495-
496-
#[test]
497-
fn test_build_delete_partial_exit_url_root_base() {
498-
let client = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
499-
let path = delete_partial_exit_url("0xpubkey", "0xlockhash", 5);
500-
let url = client.build_url(&path);
501-
assert_eq!(
502-
url.as_str(),
503-
"https://api.obol.tech/exp/partial_exits/0xlockhash/5/0xpubkey"
504-
);
505-
}
506-
507-
#[test]
508-
fn test_build_delete_partial_exit_url_v1_base() {
509-
let client = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
510-
let path = delete_partial_exit_url("0xpubkey", "0xlockhash", 5);
511-
let url = client.build_url(&path);
512-
assert_eq!(
513-
url.as_str(),
514-
"https://api.obol.tech/v1/exp/partial_exits/0xlockhash/5/0xpubkey"
515-
);
516-
}
517-
518-
#[test]
519-
fn test_build_fetch_full_exit_url_root_base() {
520-
let client = Client::new("https://api.obol.tech", ClientOptions::default()).unwrap();
521-
let path = fetch_full_exit_url("0xpubkey", "0xlockhash", 5);
522-
let url = client.build_url(&path);
523-
assert_eq!(
524-
url.as_str(),
525-
"https://api.obol.tech/exp/exit/0xlockhash/5/0xpubkey"
526-
);
527-
}
528-
529-
#[test]
530-
fn test_build_fetch_full_exit_url_v1_base() {
531-
let client = Client::new("https://api.obol.tech/v1", ClientOptions::default()).unwrap();
532-
let path = fetch_full_exit_url("0xpubkey", "0xlockhash", 5);
533-
let url = client.build_url(&path);
534-
assert_eq!(
535-
url.as_str(),
536-
"https://api.obol.tech/v1/exp/exit/0xlockhash/5/0xpubkey"
537-
);
538-
}
539-
540461
/// These test vectors were generated from Go `charon/app/obolapi` using
541462
/// fastssz
542463
#[test]

0 commit comments

Comments
 (0)