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:
parent
70fa372a2d
commit
ea10dae1e8
@ -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
|
||||
|
||||
|
||||
@ -28,13 +28,6 @@
|
||||
]
|
||||
},
|
||||
"modules" : [
|
||||
{
|
||||
"name" : "httpie",
|
||||
"buildsystem" : "simple",
|
||||
"build-commands" : [
|
||||
"pip3 install --prefix=${FLATPAK_DEST} --ignore-installed httpie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name" : "roster",
|
||||
"builddir" : true,
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user