Migrate from HTTPie to libsoup3 for HTTP requests

Replace HTTPie subprocess calls with native libsoup3 async API. This
eliminates external dependencies and uses GNOME-standard async patterns.

Changes:
- Rewrite http_client.py to use libsoup3 Session and Message APIs
- Replace threading with GLib async callbacks in window.py
- Remove HTTPie dependency from Flatpak manifest
- Update documentation to reflect libsoup3 usage

Benefits:
- No external dependencies (HTTPie removed)
- Native GLib async integration (no threading complexity)
- Better performance with connection pooling and HTTP/2 support
- Cleaner codebase following GNOME development patterns
This commit is contained in:
Pavel Baksy 2025-12-22 23:34:05 +01:00
parent 7cc3026875
commit b49d5ab7fd
4 changed files with 159 additions and 111 deletions

View File

@ -15,7 +15,7 @@ Roster is a GNOME application written in Python using GTK 4 and libadwaita. The
## Build Commands ## Build Commands
### Native Build (requires HTTPie installed on host) ### Native Build
```bash ```bash
# Configure the build (from project root) # Configure the build (from project root)
@ -36,10 +36,7 @@ rm -rf builddir
### Running the Application ### Running the Application
**IMPORTANT**: This application requires HTTPie to make HTTP requests. The application uses **libsoup3** (from GNOME Platform) for HTTP requests - no external dependencies required.
- **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`
## Development with Flatpak ## Development with Flatpak

View File

@ -28,13 +28,6 @@
] ]
}, },
"modules" : [ "modules" : [
{
"name" : "httpie",
"buildsystem" : "simple",
"build-commands" : [
"pip3 install --prefix=${FLATPAK_DEST} --ignore-installed httpie"
]
},
{ {
"name" : "roster", "name" : "roster",
"builddir" : true, "builddir" : true,

View File

@ -17,125 +17,185 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # 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 import time
from typing import Tuple, Optional from typing import Optional, Callable, Any
from .models import HttpRequest, HttpResponse from .models import HttpRequest, HttpResponse
class HttpClient: 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: Args:
request: HttpRequest object with method, url, headers, body request: HttpRequest object with method, url, headers, body
callback: Function to call with results: callback(response, error, user_data)
Returns: - response: HttpResponse object if successful, None on error
Tuple of (response, error_message) - error: Error message string if failed, None on success
If successful: (HttpResponse, None) - user_data: The user_data passed to this function
If failed: (None, error_message) user_data: Optional data to pass to callback
""" """
# Build command start_time = time.time()
cmd = ['http', '--print=HhBb', request.method, request.url]
# 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 # Add headers
headers = msg.get_request_headers()
for key, value in request.headers.items(): for key, value in request.headers.items():
if key and value: # Skip empty headers 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) # Set body for POST/PUT/DELETE
stdin_data = None
if request.method in ['POST', 'PUT', 'DELETE'] and request.body.strip(): if request.method in ['POST', 'PUT', 'DELETE'] and request.body.strip():
stdin_data = request.body.encode('utf-8') content_type = self._get_content_type(request)
else: msg.set_request_body_from_bytes(
# Only use --ignore-stdin when there's no body content_type,
cmd.insert(2, '--ignore-stdin') GLib.Bytes.new(request.body.encode('utf-8'))
# 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
) )
end_time = time.time()
response_time = (end_time - start_time) * 1000 # Convert to ms
# Parse output # Define callback wrapper to handle timing and parsing
output = result.stdout.decode('utf-8', errors='replace') 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: try:
error_msg = result.stderr.decode('utf-8', errors='replace') # Finish async operation
return None, error_msg bytes_data = session.send_and_read_finish(result)
# Parse HTTPie output # Parse response from message
response = self._parse_httpie_output(output, response_time) response = self._parse_response(msg, bytes_data, response_time)
return response, None cb(response, None, udata)
except subprocess.TimeoutExpired: except GLib.Error as e:
return None, "Request timeout (30 seconds)" # Handle libsoup errors
except FileNotFoundError: error_msg = self._format_error(e)
return None, "HTTPie not installed. Please install with: pip install httpie" cb(None, error_msg, udata)
except Exception as e:
return None, f"Error: {str(e)}"
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: Args:
REQUEST HEADERS msg: The Soup.Message with response headers and status
<blank line> bytes_data: Response body as GLib.Bytes
REQUEST BODY response_time: Response time in milliseconds
<blank line>
<blank line> Returns:
RESPONSE HEADERS (starts with HTTP/1.1 200 OK) HttpResponse object with status, headers, body, and timing
<blank line>
RESPONSE BODY
""" """
# Split output into lines to manually find response section # Extract status code and text
lines = output.split('\n') status_code = msg.get_status()
status_text = msg.get_reason_phrase()
# Find where response headers start (line starting with HTTP/) # Format headers like HTTPie output (HTTP/1.1 200 OK\nHeader: value\n...)
response_header_start = 0 headers_text = self._format_headers(msg, status_code, status_text)
for i, line in enumerate(lines):
if line.startswith('HTTP/'):
response_header_start = i
break
# Parse status line # Decode body with error handling
status_line = lines[response_header_start] body = bytes_data.get_data().decode('utf-8', errors='replace')
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:])
return HttpResponse( return HttpResponse(
status_code=status_code, status_code=status_code,
status_text=status_text, status_text=status_text,
headers=response_headers, headers=headers_text,
body=response_body.strip(), body=body.strip(),
response_time_ms=response_time 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}"

View File

@ -30,7 +30,6 @@ from .widgets.header_row import HeaderRow
from .widgets.history_item import HistoryItem from .widgets.history_item import HistoryItem
from .widgets.project_item import ProjectItem from .widgets.project_item import ProjectItem
from datetime import datetime from datetime import datetime
import threading
import json import json
import xml.dom.minidom import xml.dom.minidom
import uuid import uuid
@ -713,17 +712,16 @@ class RosterWindow(Adw.ApplicationWindow):
self.status_label.set_text("Sending...") self.status_label.set_text("Sending...")
self.time_label.set_text("") self.time_label.set_text("")
# Execute in thread to avoid blocking UI # Execute async (no threading needed!)
thread = threading.Thread(target=self._execute_request_thread, args=(request,)) self.http_client.execute_request_async(
thread.daemon = True request,
thread.start() self._handle_response_callback,
request # user_data
)
def _execute_request_thread(self, request): def _handle_response_callback(self, response, error, request):
"""Execute request in background thread.""" """Callback runs on main thread automatically."""
response, error = self.http_client.execute_request(request) self._handle_response(request, response, error)
# Update UI in main thread
GLib.idle_add(self._handle_response, request, response, error)
def _handle_response(self, request, response, error): def _handle_response(self, request, response, error):
"""Handle response in main thread.""" """Handle response in main thread."""