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:
vesp 2025-12-22 23:34:05 +01:00
parent 70fa372a2d
commit ea10dae1e8
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
### 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

View File

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

View File

@ -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
<blank line>
REQUEST BODY
<blank line>
<blank line>
RESPONSE HEADERS (starts with HTTP/1.1 200 OK)
<blank line>
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}"

View File

@ -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."""