Roster is a modern HTTP client application for GNOME, similar to Postman, built with GTK 4 and libadwaita. Features: - Send HTTP requests (GET, POST, PUT, DELETE) - Configure custom headers with add/remove functionality - Request body editor for POST/PUT/DELETE requests - View response headers and bodies in separate tabs - Track request history with JSON persistence - Load previous requests from history with confirmation dialog - Beautiful GNOME-native UI with libadwaita components - HTTPie backend for reliable HTTP communication Technical implementation: - Python 3 with GTK 4 and libadwaita 1 - Meson build system with Flatpak support - Custom widgets (HeaderRow, HistoryItem) with GObject signals - Background threading for non-blocking HTTP requests - AdwTabView for modern tabbed interface - History persistence to ~/.config/roster/history.json - Comprehensive error handling and user feedback
142 lines
4.8 KiB
Python
142 lines
4.8 KiB
Python
# http_client.py
|
|
#
|
|
# Copyright 2025 Pavel Baksy
|
|
#
|
|
# This program is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# This program is distributed in the hope that it will be useful,
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
# GNU General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
#
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
import subprocess
|
|
import time
|
|
from typing import Tuple, Optional
|
|
from .models import HttpRequest, HttpResponse
|
|
|
|
|
|
class HttpClient:
|
|
"""HTTP client using HTTPie as backend."""
|
|
|
|
def execute_request(self, request: HttpRequest) -> Tuple[Optional[HttpResponse], Optional[str]]:
|
|
"""
|
|
Execute HTTP request using HTTPie.
|
|
|
|
Args:
|
|
request: HttpRequest object with method, url, headers, body
|
|
|
|
Returns:
|
|
Tuple of (response, error_message)
|
|
If successful: (HttpResponse, None)
|
|
If failed: (None, error_message)
|
|
"""
|
|
# Build command
|
|
cmd = ['http', '--print=HhBb', request.method, request.url]
|
|
|
|
# Add headers
|
|
for key, value in request.headers.items():
|
|
if key and value: # Skip empty headers
|
|
cmd.append(f'{key}:{value}')
|
|
|
|
# Prepare stdin for body (if POST/PUT/DELETE and body exists)
|
|
stdin_data = None
|
|
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
|
|
)
|
|
end_time = time.time()
|
|
response_time = (end_time - start_time) * 1000 # Convert to ms
|
|
|
|
# Parse output
|
|
output = result.stdout.decode('utf-8', errors='replace')
|
|
|
|
if result.returncode != 0:
|
|
error_msg = result.stderr.decode('utf-8', errors='replace')
|
|
return None, error_msg
|
|
|
|
# Parse HTTPie output
|
|
response = self._parse_httpie_output(output, response_time)
|
|
return response, None
|
|
|
|
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)}"
|
|
|
|
def _parse_httpie_output(self, output: str, response_time: float) -> HttpResponse:
|
|
"""
|
|
Parse HTTPie --print=HhBb output format.
|
|
|
|
Format:
|
|
REQUEST HEADERS
|
|
<blank line>
|
|
REQUEST BODY
|
|
<blank line>
|
|
<blank line>
|
|
RESPONSE HEADERS (starts with HTTP/1.1 200 OK)
|
|
<blank line>
|
|
RESPONSE BODY
|
|
"""
|
|
# Split output into lines to manually find response section
|
|
lines = output.split('\n')
|
|
|
|
# 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
|
|
|
|
# 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:])
|
|
|
|
return HttpResponse(
|
|
status_code=status_code,
|
|
status_text=status_text,
|
|
headers=response_headers,
|
|
body=response_body.strip(),
|
|
response_time_ms=response_time
|
|
)
|