Skip to content

Commit d1782cb

Browse files
committed
refact: cleanup complex routines
1 parent a5f6ad8 commit d1782cb

File tree

19 files changed

+592
-518
lines changed

19 files changed

+592
-518
lines changed

packages/hop3-cli/src/hop3_cli/client.py

Lines changed: 105 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -181,110 +181,126 @@ def rpc(
181181
}
182182
json_request = request(method, args)
183183

184-
# Determine SSL verification mode
185-
# Options:
186-
# - ssl_cert: path to a trusted certificate file (for self-signed certs)
187-
# - verify_ssl: false to disable verification entirely (last resort)
188-
#
189-
# For IP addresses with ssl_cert: We skip SSL verification because:
190-
# 1. Standard verification fails (cert is for hostname, not IP)
191-
# 2. The certificate was fetched via SSH (trusted channel) during init
192-
# 3. We're connecting to the same server we SSH'd into
193-
parsed_url = urlparse(self.api_url)
194-
verify_ssl: bool | str = True
195-
196-
if parsed_url.scheme == "https":
197-
ssl_cert = self.config.get("ssl_cert", None)
198-
verify_ssl_config = self.config.get("verify_ssl", None)
199-
hostname = parsed_url.hostname or ""
200-
201-
# Parse verify_ssl setting
202-
verify_ssl_disabled = False
203-
if verify_ssl_config is not None:
204-
if isinstance(verify_ssl_config, str):
205-
verify_ssl_disabled = verify_ssl_config.lower() in (
206-
"false",
207-
"0",
208-
"no",
209-
)
210-
else:
211-
verify_ssl_disabled = not bool(verify_ssl_config)
212-
213-
if ssl_cert and _is_ip_address(hostname):
214-
# IP address with saved cert: skip verification
215-
# (cert was fetched via trusted SSH, hostname check would fail)
216-
verify_ssl = False
217-
elif ssl_cert:
218-
# Hostname with saved cert: standard verification with custom CA
219-
verify_ssl = ssl_cert
220-
elif verify_ssl_disabled:
221-
# No cert and verification explicitly disabled
222-
verify_ssl = False
223-
# else: default to True (system CA bundle)
224-
else:
225-
verify_ssl = False
226-
227-
# Build headers with authentication
228-
headers = {"Content-Type": "application/json"}
229-
230-
# Add authentication token if configured
231-
# Note: Even with SSH tunnel, we still use token for authorization
232-
# SSH provides transport security, token provides authorization
233-
api_token = self.config.get("api_token", "")
234-
if api_token:
235-
headers["Authorization"] = f"Bearer {api_token}"
184+
verify_ssl = self._get_ssl_verification()
185+
headers = self._build_headers()
236186

237187
response = requests.post(
238188
self.rpc_url,
239189
json=json_request,
240190
headers=headers,
241191
verify=verify_ssl,
242192
)
193+
194+
return self._parse_response(response, json_request)
195+
196+
def _get_ssl_verification(self) -> bool | str:
197+
"""Determine SSL verification mode based on config."""
198+
parsed_url = urlparse(self.api_url)
199+
200+
if parsed_url.scheme != "https":
201+
return False
202+
203+
ssl_cert = self.config.get("ssl_cert", None)
204+
verify_ssl_config = self.config.get("verify_ssl", None)
205+
hostname = parsed_url.hostname or ""
206+
207+
# Check if verification is explicitly disabled
208+
verify_ssl_disabled = self._is_verify_ssl_disabled(verify_ssl_config)
209+
210+
if ssl_cert and _is_ip_address(hostname):
211+
# IP address with saved cert: skip verification
212+
# (cert was fetched via trusted SSH, hostname check would fail)
213+
return False
214+
if ssl_cert:
215+
# Hostname with saved cert: standard verification with custom CA
216+
return ssl_cert
217+
218+
# Default to system CA bundle unless explicitly disabled
219+
return not verify_ssl_disabled
220+
221+
def _is_verify_ssl_disabled(self, verify_ssl_config: str | bool | None) -> bool:
222+
"""Check if SSL verification is explicitly disabled in config."""
223+
if verify_ssl_config is None:
224+
return False
225+
if isinstance(verify_ssl_config, str):
226+
return verify_ssl_config.lower() in ("false", "0", "no")
227+
return not bool(verify_ssl_config)
228+
229+
def _build_headers(self) -> dict[str, str]:
230+
"""Build request headers with authentication."""
231+
headers = {"Content-Type": "application/json"}
232+
api_token = self.config.get("api_token", "")
233+
if api_token:
234+
headers["Authorization"] = f"Bearer {api_token}"
235+
return headers
236+
237+
def _parse_response(
238+
self, response: requests.Response, json_request: dict
239+
) -> Response:
240+
"""Parse HTTP response into JSON-RPC response."""
241+
request_id = json_request["id"]
242+
243243
try:
244-
# Check for 401 Unauthorized specifically
245244
if response.status_code == 401:
246-
error_msg = (
247-
"Authentication required.\n\n"
248-
"To authenticate, use one of the following methods:\n"
249-
" 1. Login: hop auth:login <username> <password>\n"
250-
" 2. Register: hop auth:register <username> <email> <password>\n\n"
251-
"After logging in, save the token to ~/.config/hop3-cli/config.toml\n"
252-
"or set the HOP3_API_TOKEN environment variable."
253-
)
254-
return Error(401, error_msg, "", json_request["id"])
245+
return self._make_auth_error(request_id)
246+
247+
# Try to parse as JSON-RPC response (even for non-200 status codes)
248+
json_rpc_response = self._try_parse_jsonrpc(response, request_id)
249+
if json_rpc_response is not None:
250+
return json_rpc_response
255251

252+
# For non-200 responses without JSON-RPC error, raise HTTP error
256253
response.raise_for_status()
257-
parsed_response = parse(response.json())
258-
# parse() can return Error, Ok, or Iterable[Error | Ok], but we expect single response
254+
return Error(-1, "Unexpected response format", "", request_id)
255+
256+
except requests.exceptions.HTTPError as e:
257+
error_detail = f"HTTP {response.status_code} error: {e!s}"
258+
if response.text:
259+
error_detail += f"\nResponse: {response.text[:500]}"
260+
return Error(response.status_code, error_detail, "", request_id)
261+
except Exception as e:
262+
return Error(response.status_code, str(e), "", request_id)
263+
264+
def _make_auth_error(self, request_id: int) -> Error:
265+
"""Create authentication required error."""
266+
error_msg = (
267+
"Authentication required.\n\n"
268+
"To authenticate, use one of the following methods:\n"
269+
" 1. Login: hop auth:login <username> <password>\n"
270+
" 2. Register: hop auth:register <username> <email> <password>\n\n"
271+
"After logging in, save the token to ~/.config/hop3-cli/config.toml\n"
272+
"or set the HOP3_API_TOKEN environment variable."
273+
)
274+
return Error(401, error_msg, "", request_id)
275+
276+
def _try_parse_jsonrpc(
277+
self, response: requests.Response, request_id: int
278+
) -> Response | None:
279+
"""Try to parse response as JSON-RPC. Returns None if not valid JSON-RPC."""
280+
try:
281+
json_body = response.json()
282+
except ValueError:
283+
return None
284+
285+
# Check if this is a JSON-RPC error response
286+
if "error" in json_body and isinstance(json_body["error"], dict):
287+
rpc_error = json_body["error"]
288+
return Error(
289+
rpc_error.get("code", response.status_code),
290+
rpc_error.get("message", "Unknown error"),
291+
rpc_error.get("data", ""),
292+
request_id,
293+
)
294+
295+
# Parse successful JSON-RPC response
296+
if response.ok:
297+
parsed_response = parse(json_body)
259298
if isinstance(parsed_response, (Error, Ok)):
260299
return parsed_response
261300
# Handle batch responses - take first response
262301
responses = list(parsed_response)
263302
if responses and isinstance(responses[0], (Error, Ok)):
264303
return responses[0]
265-
return Error(-1, "Invalid response format", "", json_request["id"])
266-
except requests.exceptions.HTTPError as e:
267-
# For other HTTP errors, provide the status code and message
268-
# Try to extract error details from response body
269-
error_detail = f"HTTP {response.status_code} error: {e!s}"
270-
try:
271-
error_body = response.json()
272-
if "detail" in error_body:
273-
error_detail += f"\nDetail: {error_body['detail']}"
274-
elif "error" in error_body:
275-
error_detail += f"\nError: {error_body['error']}"
276-
else:
277-
error_detail += f"\nResponse: {error_body}"
278-
except Exception:
279-
# If we can't parse JSON, try to show raw response
280-
if response.text:
281-
error_detail += f"\nResponse: {response.text[:500]}"
304+
return Error(-1, "Invalid response format", "", request_id)
282305

283-
return Error(
284-
response.status_code,
285-
error_detail,
286-
"",
287-
json_request["id"],
288-
)
289-
except Exception as e:
290-
return Error(response.status_code, str(e), "", json_request["id"])
306+
return None

0 commit comments

Comments
 (0)