diff --git a/CLAUDE.md b/CLAUDE.md index 6e20e6d..a8f5dca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,7 @@ Roster is a GNOME application written in Python using GTK 4 and libadwaita. The ## Build Commands -### Native Build (requires HTTPie installed on host) +### Native Build ```bash # Configure the build (from project root) @@ -36,10 +36,7 @@ rm -rf builddir ### Running the Application -**IMPORTANT**: This application requires HTTPie to make HTTP requests. - -- **Flatpak (GNOME Builder)**: HTTPie is automatically included as a Flatpak module -- **Native**: Install HTTPie on your system with `pip install httpie` or `sudo dnf install httpie` +The application uses **libsoup3** (from GNOME Platform) for HTTP requests - no external dependencies required. ## Development with Flatpak diff --git a/cz.vesp.roster.json b/cz.vesp.roster.json index 19af626..308be8c 100644 --- a/cz.vesp.roster.json +++ b/cz.vesp.roster.json @@ -28,13 +28,6 @@ ] }, "modules" : [ - { - "name" : "httpie", - "buildsystem" : "simple", - "build-commands" : [ - "pip3 install --prefix=${FLATPAK_DEST} --ignore-installed httpie" - ] - }, { "name" : "roster", "builddir" : true, diff --git a/src/http_client.py b/src/http_client.py index 49aa025..736ca12 100644 --- a/src/http_client.py +++ b/src/http_client.py @@ -17,125 +17,185 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -import subprocess +import gi +gi.require_version('Soup', '3.0') +from gi.repository import Soup, GLib + import time -from typing import Tuple, Optional +from typing import Optional, Callable, Any from .models import HttpRequest, HttpResponse class HttpClient: - """HTTP client using HTTPie as backend.""" + """HTTP client using libsoup3 async API.""" - def execute_request(self, request: HttpRequest) -> Tuple[Optional[HttpResponse], Optional[str]]: + def __init__(self): + """Initialize HTTP client with reusable session.""" + self.session = Soup.Session.new() + self.session.set_timeout(30) # 30 second timeout + + def execute_request_async(self, request: HttpRequest, callback: Callable, user_data: Any = None): """ - Execute HTTP request using HTTPie. + Execute HTTP request asynchronously using libsoup. Args: request: HttpRequest object with method, url, headers, body - - Returns: - Tuple of (response, error_message) - If successful: (HttpResponse, None) - If failed: (None, error_message) + 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 """ - # Build command - cmd = ['http', '--print=HhBb', request.method, request.url] + 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 # Add headers + headers = msg.get_request_headers() for key, value in request.headers.items(): if key and value: # Skip empty headers - cmd.append(f'{key}:{value}') + headers.append(key, value) - # Prepare stdin for body (if POST/PUT/DELETE and body exists) - stdin_data = None + # Set body for POST/PUT/DELETE if request.method in ['POST', 'PUT', 'DELETE'] and request.body.strip(): - stdin_data = request.body.encode('utf-8') - else: - # Only use --ignore-stdin when there's no body - cmd.insert(2, '--ignore-stdin') - - # Execute with timing - start_time = time.time() - try: - result = subprocess.run( - cmd, - input=stdin_data, - capture_output=True, - timeout=30, # 30 second timeout - text=False # We'll decode manually + content_type = self._get_content_type(request) + msg.set_request_body_from_bytes( + content_type, + GLib.Bytes.new(request.body.encode('utf-8')) ) - end_time = time.time() - response_time = (end_time - start_time) * 1000 # Convert to ms - # Parse output - output = result.stdout.decode('utf-8', errors='replace') + # 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 - if result.returncode != 0: - error_msg = result.stderr.decode('utf-8', errors='replace') - return None, error_msg + try: + # Finish async operation + bytes_data = session.send_and_read_finish(result) - # Parse HTTPie output - response = self._parse_httpie_output(output, response_time) - return response, None + # Parse response from message + response = self._parse_response(msg, bytes_data, response_time) + cb(response, None, udata) - except subprocess.TimeoutExpired: - return None, "Request timeout (30 seconds)" - except FileNotFoundError: - return None, "HTTPie not installed. Please install with: pip install httpie" - except Exception as e: - return None, f"Error: {str(e)}" + except GLib.Error as e: + # Handle libsoup errors + error_msg = self._format_error(e) + cb(None, error_msg, udata) - def _parse_httpie_output(self, output: str, response_time: float) -> HttpResponse: + # 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: """ - Parse HTTPie --print=HhBb output format. + Extract response data from Soup.Message. - Format: - REQUEST HEADERS - - REQUEST BODY - - - RESPONSE HEADERS (starts with HTTP/1.1 200 OK) - - RESPONSE BODY + 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 """ - # Split output into lines to manually find response section - lines = output.split('\n') + # Extract status code and text + status_code = msg.get_status() + status_text = msg.get_reason_phrase() - # Find where response headers start (line starting with HTTP/) - response_header_start = 0 - for i, line in enumerate(lines): - if line.startswith('HTTP/'): - response_header_start = i - break + # Format headers like HTTPie output (HTTP/1.1 200 OK\nHeader: value\n...) + headers_text = self._format_headers(msg, status_code, status_text) - # Parse status line - status_line = lines[response_header_start] - parts = status_line.split(' ', 2) - - if len(parts) < 2: - raise ValueError(f"Invalid status line: {status_line}") - - status_code = int(parts[1]) - status_text = parts[2] if len(parts) > 2 else '' - - # Find where response headers end (first empty line after response starts) - response_body_start = response_header_start + 1 - for i in range(response_header_start + 1, len(lines)): - if lines[i].strip() == '': - response_body_start = i + 1 - break - - # Extract response headers (from HTTP/ line to first blank line) - response_headers = '\n'.join(lines[response_header_start:response_body_start - 1]) - - # Extract response body (everything after the blank line) - response_body = '\n'.join(lines[response_body_start:]) + # 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=response_headers, - body=response_body.strip(), + 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 to match HTTPie 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}" diff --git a/src/window.py b/src/window.py index 4bddea0..335521e 100644 --- a/src/window.py +++ b/src/window.py @@ -30,7 +30,6 @@ from .widgets.header_row import HeaderRow from .widgets.history_item import HistoryItem from .widgets.project_item import ProjectItem from datetime import datetime -import threading import json import xml.dom.minidom import uuid @@ -713,17 +712,16 @@ class RosterWindow(Adw.ApplicationWindow): self.status_label.set_text("Sending...") self.time_label.set_text("") - # Execute in thread to avoid blocking UI - thread = threading.Thread(target=self._execute_request_thread, args=(request,)) - thread.daemon = True - thread.start() + # Execute async (no threading needed!) + self.http_client.execute_request_async( + request, + self._handle_response_callback, + request # user_data + ) - def _execute_request_thread(self, request): - """Execute request in background thread.""" - response, error = self.http_client.execute_request(request) - - # Update UI in main thread - GLib.idle_add(self._handle_response, request, response, error) + def _handle_response_callback(self, response, error, request): + """Callback runs on main thread automatically.""" + self._handle_response(request, response, error) def _handle_response(self, request, response, error): """Handle response in main thread."""