@@ -14,14 +14,11 @@ use crate::obolapi::error::{Error, Result};
1414/// Default HTTP request timeout if not specified.
1515const 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 ) ]
2219pub 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 {
5350impl 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
210200fn 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}
0 commit comments