@@ -313,6 +313,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
313313 private var currentTask : URLSessionDataTask ?
314314 private var authToken : String ?
315315 private var _isAuthenticating = false
316+ private var apiPrefix = " v3 "
316317
317318 private static let logger = Logger ( subsystem: " com.TablePro " , category: " EtcdHttpClient " )
318319
@@ -332,6 +333,13 @@ internal final class EtcdHttpClient: @unchecked Sendable {
332333 return " \( scheme) :// \( config. host) : \( config. port) "
333334 }
334335
336+ private func apiPath( _ suffix: String ) -> String {
337+ lock. lock ( )
338+ let prefix = apiPrefix
339+ lock. unlock ( )
340+ return " \( prefix) / \( suffix) "
341+ }
342+
335343 // MARK: - Connection Lifecycle
336344
337345 func connect( ) async throws {
@@ -366,7 +374,7 @@ internal final class EtcdHttpClient: @unchecked Sendable {
366374 lock. unlock ( )
367375
368376 do {
369- try await ping ( )
377+ try await detectApiPrefix ( )
370378 } catch {
371379 lock. lock ( )
372380 session? . invalidateAndCancel ( )
@@ -399,56 +407,104 @@ internal final class EtcdHttpClient: @unchecked Sendable {
399407 session = nil
400408 authToken = nil
401409 _isAuthenticating = false
410+ apiPrefix = " v3 "
402411 lock. unlock ( )
403412 }
404413
405414 func ping( ) async throws {
406- let _: EtcdStatusResponse = try await post ( path: " v3/maintenance/status " , body: EmptyBody ( ) )
415+ let _: EtcdStatusResponse = try await post ( path: apiPath ( " maintenance/status " ) , body: EmptyBody ( ) )
416+ }
417+
418+ private func detectApiPrefix( ) async throws {
419+ let candidates = [ " v3 " , " v3beta " ]
420+
421+ lock. lock ( )
422+ guard let session else {
423+ lock. unlock ( )
424+ throw EtcdError . notConnected
425+ }
426+ lock. unlock ( )
427+
428+ for candidate in candidates {
429+ guard let url = URL ( string: " \( baseUrl) / \( candidate) /maintenance/status " ) else {
430+ continue
431+ }
432+
433+ var request = URLRequest ( url: url)
434+ request. httpMethod = " POST "
435+ request. setValue ( " application/json " , forHTTPHeaderField: " Content-Type " )
436+ request. httpBody = try JSONEncoder ( ) . encode ( EmptyBody ( ) )
437+
438+ let response : URLResponse
439+ do {
440+ ( _, response) = try await session. data ( for: request)
441+ } catch {
442+ throw EtcdError . connectionFailed ( error. localizedDescription)
443+ }
444+
445+ guard let httpResponse = response as? HTTPURLResponse else {
446+ throw EtcdError . serverError ( " Invalid response type " )
447+ }
448+
449+ if httpResponse. statusCode == 404 {
450+ continue
451+ }
452+
453+ lock. lock ( )
454+ apiPrefix = candidate
455+ lock. unlock ( )
456+ Self . logger. debug ( " Detected etcd API prefix: \( candidate) " )
457+ return
458+ }
459+
460+ lock. lock ( )
461+ apiPrefix = " v3 "
462+ lock. unlock ( )
407463 }
408464
409465 // MARK: - KV Operations
410466
411467 func rangeRequest( _ req: EtcdRangeRequest ) async throws -> EtcdRangeResponse {
412- try await post ( path: " v3/ kv/range" , body: req)
468+ try await post ( path: apiPath ( " kv/range " ) , body: req)
413469 }
414470
415471 func putRequest( _ req: EtcdPutRequest ) async throws -> EtcdPutResponse {
416- try await post ( path: " v3/ kv/put" , body: req)
472+ try await post ( path: apiPath ( " kv/put " ) , body: req)
417473 }
418474
419475 func deleteRequest( _ req: EtcdDeleteRequest ) async throws -> EtcdDeleteResponse {
420- try await post ( path: " v3/ kv/deleterange" , body: req)
476+ try await post ( path: apiPath ( " kv/deleterange " ) , body: req)
421477 }
422478
423479 // MARK: - Lease Operations
424480
425481 func leaseGrant( ttl: Int64 ) async throws -> EtcdLeaseGrantResponse {
426482 let req = EtcdLeaseGrantRequest ( TTL: String ( ttl) )
427- return try await post ( path: " v3/ lease/grant" , body: req)
483+ return try await post ( path: apiPath ( " lease/grant " ) , body: req)
428484 }
429485
430486 func leaseRevoke( leaseId: Int64 ) async throws {
431487 let req = EtcdLeaseRevokeRequest ( ID: String ( leaseId) )
432- try await postVoid ( path: " v3/ lease/revoke" , body: req)
488+ try await postVoid ( path: apiPath ( " lease/revoke " ) , body: req)
433489 }
434490
435491 func leaseTimeToLive( leaseId: Int64 , keys: Bool ) async throws -> EtcdLeaseTimeToLiveResponse {
436492 let req = EtcdLeaseTimeToLiveRequest ( ID: String ( leaseId) , keys: keys)
437- return try await post ( path: " v3/ lease/timetolive" , body: req)
493+ return try await post ( path: apiPath ( " lease/timetolive " ) , body: req)
438494 }
439495
440496 func leaseList( ) async throws -> EtcdLeaseListResponse {
441- try await post ( path: " v3/ lease/leases" , body: EmptyBody ( ) )
497+ try await post ( path: apiPath ( " lease/leases " ) , body: EmptyBody ( ) )
442498 }
443499
444500 // MARK: - Cluster Operations
445501
446502 func memberList( ) async throws -> EtcdMemberListResponse {
447- try await post ( path: " v3/ cluster/member/list" , body: EmptyBody ( ) )
503+ try await post ( path: apiPath ( " cluster/member/list " ) , body: EmptyBody ( ) )
448504 }
449505
450506 func endpointStatus( ) async throws -> EtcdStatusResponse {
451- try await post ( path: " v3/ maintenance/status" , body: EmptyBody ( ) )
507+ try await post ( path: apiPath ( " maintenance/status " ) , body: EmptyBody ( ) )
452508 }
453509
454510 // MARK: - Watch
@@ -469,8 +525,9 @@ internal final class EtcdHttpClient: @unchecked Sendable {
469525 }
470526 let watchReq = EtcdWatchRequest ( createRequest: createReq)
471527
472- guard let url = URL ( string: " \( baseUrl) /v3/watch " ) else {
473- throw EtcdError . serverError ( " Invalid URL: \( baseUrl) /v3/watch " )
528+ let watchPath = apiPath ( " watch " )
529+ guard let url = URL ( string: " \( baseUrl) / \( watchPath) " ) else {
530+ throw EtcdError . serverError ( " Invalid URL: \( baseUrl) / \( watchPath) " )
474531 }
475532
476533 var request = URLRequest ( url: url)
@@ -525,58 +582,58 @@ internal final class EtcdHttpClient: @unchecked Sendable {
525582 // MARK: - Auth Management
526583
527584 func authEnable( ) async throws {
528- try await postVoid ( path: " v3/ auth/enable" , body: EmptyBody ( ) )
585+ try await postVoid ( path: apiPath ( " auth/enable " ) , body: EmptyBody ( ) )
529586 }
530587
531588 func authDisable( ) async throws {
532- try await postVoid ( path: " v3/ auth/disable" , body: EmptyBody ( ) )
589+ try await postVoid ( path: apiPath ( " auth/disable " ) , body: EmptyBody ( ) )
533590 }
534591
535592 func userAdd( name: String , password: String ) async throws {
536593 let req = EtcdUserAddRequest ( name: name, password: password)
537- try await postVoid ( path: " v3/ auth/user/add" , body: req)
594+ try await postVoid ( path: apiPath ( " auth/user/add " ) , body: req)
538595 }
539596
540597 func userDelete( name: String ) async throws {
541598 let req = EtcdUserDeleteRequest ( name: name)
542- try await postVoid ( path: " v3/ auth/user/delete" , body: req)
599+ try await postVoid ( path: apiPath ( " auth/user/delete " ) , body: req)
543600 }
544601
545602 func userList( ) async throws -> [ String ] {
546- let resp : EtcdUserListResponse = try await post ( path: " v3/ auth/user/list" , body: EmptyBody ( ) )
603+ let resp : EtcdUserListResponse = try await post ( path: apiPath ( " auth/user/list " ) , body: EmptyBody ( ) )
547604 return resp. users ?? [ ]
548605 }
549606
550607 func roleAdd( name: String ) async throws {
551608 let req = EtcdRoleAddRequest ( name: name)
552- try await postVoid ( path: " v3/ auth/role/add" , body: req)
609+ try await postVoid ( path: apiPath ( " auth/role/add " ) , body: req)
553610 }
554611
555612 func roleDelete( name: String ) async throws {
556613 let req = EtcdRoleDeleteRequest ( name: name)
557- try await postVoid ( path: " v3/ auth/role/delete" , body: req)
614+ try await postVoid ( path: apiPath ( " auth/role/delete " ) , body: req)
558615 }
559616
560617 func roleList( ) async throws -> [ String ] {
561- let resp : EtcdRoleListResponse = try await post ( path: " v3/ auth/role/list" , body: EmptyBody ( ) )
618+ let resp : EtcdRoleListResponse = try await post ( path: apiPath ( " auth/role/list " ) , body: EmptyBody ( ) )
562619 return resp. roles ?? [ ]
563620 }
564621
565622 func userGrantRole( user: String , role: String ) async throws {
566623 let req = EtcdUserGrantRoleRequest ( user: user, role: role)
567- try await postVoid ( path: " v3/ auth/user/grant" , body: req)
624+ try await postVoid ( path: apiPath ( " auth/user/grant " ) , body: req)
568625 }
569626
570627 func userRevokeRole( user: String , role: String ) async throws {
571628 let req = EtcdUserRevokeRoleRequest ( user: user, role: role)
572- try await postVoid ( path: " v3/ auth/user/revoke" , body: req)
629+ try await postVoid ( path: apiPath ( " auth/user/revoke " ) , body: req)
573630 }
574631
575632 // MARK: - Maintenance
576633
577634 func compaction( revision: Int64 , physical: Bool ) async throws {
578635 let req = EtcdCompactionRequest ( revision: String ( revision) , physical: physical)
579- try await postVoid ( path: " v3/ kv/compaction" , body: req)
636+ try await postVoid ( path: apiPath ( " kv/compaction " ) , body: req)
580637 }
581638
582639 // MARK: - Cancellation
@@ -710,7 +767,8 @@ internal final class EtcdHttpClient: @unchecked Sendable {
710767 }
711768
712769 let authReq = EtcdAuthRequest ( name: config. username, password: config. password)
713- guard let url = URL ( string: " \( baseUrl) /v3/auth/authenticate " ) else {
770+ let authPath = apiPath ( " auth/authenticate " )
771+ guard let url = URL ( string: " \( baseUrl) / \( authPath) " ) else {
714772 throw EtcdError . serverError ( " Invalid auth URL " )
715773 }
716774
0 commit comments