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
|
## 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
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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}"
|
||||||
|
|||||||
@ -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."""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user