roster/src/http_client.py
Pavel Baksy 5fa104db69 Remove incorrect HTTPie dependency from documentation
HTTPie was listed as a dependency but the application actually uses libsoup3 from GNOME Platform. Updated README to reflect correct dependencies and removed installation instructions for HTTPie. Also corrected code comments to reference libsoup3 instead of HTTPie.
2025-12-26 02:09:01 +01:00

236 lines
8.1 KiB
Python

# http_client.py
#
# Copyright 2025 Pavel Baksy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import gi
gi.require_version('Soup', '3.0')
from gi.repository import Soup, GLib, Gio
import time
from typing import Optional, Callable, Any
from .models import HttpRequest, HttpResponse
class HttpClient:
"""HTTP client using libsoup3 async API."""
def __init__(self):
"""Initialize HTTP client with reusable session."""
self.session = Soup.Session.new()
# Load settings
self.settings = Gio.Settings.new('cz.vesp.roster')
# Initialize TLS verification flag
self.force_tls_verification = True
# Apply initial settings
self._apply_settings()
# Listen for settings changes
self.settings.connect('changed::force-tls-verification', self._on_settings_changed)
self.settings.connect('changed::request-timeout', self._on_settings_changed)
def _apply_settings(self):
"""Apply settings to the HTTP session."""
# Store TLS verification preference (will be applied per-message)
self.force_tls_verification = self.settings.get_boolean('force-tls-verification')
# Apply timeout setting
timeout = self.settings.get_int('request-timeout')
self.session.set_timeout(timeout)
def _on_settings_changed(self, settings, key):
"""Handle settings changes."""
self._apply_settings()
def _accept_all_certificates(self, msg, tls_certificate, tls_errors):
"""Accept all TLS certificates when verification is disabled."""
return True # Accept certificate regardless of errors
def execute_request_async(self, request: HttpRequest, callback: Callable, user_data: Any = None):
"""
Execute HTTP request asynchronously using libsoup.
Args:
request: HttpRequest object with method, url, headers, body
callback: Function to call with results: callback(response, error, user_data)
- response: HttpResponse object if successful, None on error
- error: Error message string if failed, None on success
- user_data: The user_data passed to this function
user_data: Optional data to pass to callback
"""
start_time = time.time()
# Validate and create message
if not request.url or not request.url.strip():
callback(None, "Invalid URL: URL is empty", user_data)
return
try:
msg = Soup.Message.new(request.method, request.url)
except Exception as e:
callback(None, f"Invalid URL: {e}", user_data)
return
# Handle TLS certificate verification
if not self.force_tls_verification:
# Connect signal to accept all certificates when verification is disabled
msg.connect('accept-certificate', self._accept_all_certificates)
# Add headers
headers = msg.get_request_headers()
for key, value in request.headers.items():
if key and value: # Skip empty headers
headers.append(key, value)
# Set body for POST/PUT/DELETE
if request.method in ['POST', 'PUT', 'DELETE'] and request.body.strip():
content_type = self._get_content_type(request)
msg.set_request_body_from_bytes(
content_type,
GLib.Bytes.new(request.body.encode('utf-8'))
)
# Define callback wrapper to handle timing and parsing
def on_response_ready(session, result, user_data_tuple):
cb, udata, start = user_data_tuple
response_time = (time.time() - start) * 1000 # Convert to ms
try:
# Finish async operation
bytes_data = session.send_and_read_finish(result)
# Parse response from message
response = self._parse_response(msg, bytes_data, response_time)
cb(response, None, udata)
except GLib.Error as e:
# Handle libsoup errors
error_msg = self._format_error(e)
cb(None, error_msg, udata)
# Send async request
self.session.send_and_read_async(
msg,
GLib.PRIORITY_DEFAULT,
None, # cancellable
on_response_ready,
(callback, user_data, start_time)
)
def _parse_response(self, msg: Soup.Message, bytes_data: GLib.Bytes, response_time: float) -> HttpResponse:
"""
Extract response data from Soup.Message.
Args:
msg: The Soup.Message with response headers and status
bytes_data: Response body as GLib.Bytes
response_time: Response time in milliseconds
Returns:
HttpResponse object with status, headers, body, and timing
"""
# Extract status code and text
status_code = msg.get_status()
status_text = msg.get_reason_phrase()
# Format headers in HTTPie-style output (HTTP/1.1 200 OK\nHeader: value\n...)
headers_text = self._format_headers(msg, status_code, status_text)
# Decode body with error handling
body = bytes_data.get_data().decode('utf-8', errors='replace')
return HttpResponse(
status_code=status_code,
status_text=status_text,
headers=headers_text,
body=body.strip(),
response_time_ms=response_time
)
def _format_headers(self, msg: Soup.Message, status_code: int, status_text: str) -> str:
"""
Format response headers in HTTPie-style output format.
Format:
HTTP/1.1 200 OK
Header-Name: value
Another-Header: value
Args:
msg: The Soup.Message to extract headers from
status_code: HTTP status code
status_text: HTTP status text (e.g., "OK")
Returns:
Formatted header string
"""
# Start with status line
lines = [f"HTTP/1.1 {status_code} {status_text}"]
# Get response headers and iterate
headers_dict = msg.get_response_headers()
# Use foreach to iterate through headers
def append_header(name, value, lines_list):
lines_list.append(f"{name}: {value}")
headers_dict.foreach(append_header, lines)
return '\n'.join(lines)
def _get_content_type(self, request: HttpRequest) -> str:
"""
Determine content-type for request body.
Args:
request: HttpRequest with headers
Returns:
Content-Type string (defaults to text/plain)
"""
# Check if user specified Content-Type header
for key, value in request.headers.items():
if key.lower() == 'content-type':
return value
# Default to plain text
return "text/plain"
def _format_error(self, error: GLib.Error) -> str:
"""
Convert GLib.Error to user-friendly message.
Args:
error: GLib.Error from libsoup
Returns:
User-friendly error message string
"""
error_str = str(error).lower()
if "timeout" in error_str:
return "Request timeout (30 seconds)"
elif "resolve" in error_str:
return f"Could not resolve hostname: {error}"
elif "connect" in error_str:
return f"Connection failed: {error}"
else:
return f"Request failed: {error}"