AI-first API testing desktop client built with Python + PyQt6. Features: - Multi-tab HTTP request editor with params/headers/body/auth/tests - KeyValueTable with per-row enable/disable checkboxes and 36px rows - Format JSON button, syntax highlighting, pre-request & test scripts - Collections, environments, history, import/export (Postman v2.1, cURL) - OpenAPI 3.x / Swagger 2.0 local parser (no AI tokens) - EKIKA Odoo API Framework generator — JSON-API, REST JSON, GraphQL, Custom REST JSON with all auth types (instant, no AI tokens) - Persistent AI chat sidebar (Claude-powered co-pilot) with streaming, context-aware suggestions, and one-click Apply to request editor - AI collection generator from any docs URL or pasted spec - WebSocket client, Mock server, Collection runner, Code generator - Dark/light theme engine (global QSS, object-name selectors) - SSL error detection with actionable hints - MIT License Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
162 lines
6.0 KiB
Python
162 lines
6.0 KiB
Python
"""APIClient - Agent — HTTP client engine."""
|
|
import re
|
|
import base64
|
|
from copy import deepcopy
|
|
|
|
import httpx
|
|
|
|
from app.models import HttpRequest, HttpResponse
|
|
|
|
|
|
def resolve_variables(text: str, variables: dict) -> str:
|
|
"""Replace {{variable}} placeholders with environment values."""
|
|
if not text or not variables:
|
|
return text
|
|
|
|
def replacer(m):
|
|
key = m.group(1).strip()
|
|
return str(variables.get(key, m.group(0)))
|
|
|
|
return re.sub(r"\{\{(.+?)\}\}", replacer, text)
|
|
|
|
|
|
def apply_variables(req: HttpRequest, variables: dict) -> HttpRequest:
|
|
"""Return a deep copy of the request with all variables resolved."""
|
|
r = deepcopy(req)
|
|
r.url = resolve_variables(r.url, variables)
|
|
r.body = resolve_variables(r.body, variables)
|
|
r.headers = {k: resolve_variables(v, variables) for k, v in r.headers.items()}
|
|
r.params = {k: resolve_variables(v, variables) for k, v in r.params.items()}
|
|
if r.auth_data:
|
|
r.auth_data = {k: resolve_variables(str(v), variables) for k, v in r.auth_data.items()}
|
|
return r
|
|
|
|
|
|
def _build_auth_headers(req: HttpRequest) -> dict:
|
|
headers = {}
|
|
if req.auth_type == "bearer":
|
|
token = req.auth_data.get("token", "")
|
|
if token:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
elif req.auth_type == "basic":
|
|
user = req.auth_data.get("username", "")
|
|
pwd = req.auth_data.get("password", "")
|
|
encoded = base64.b64encode(f"{user}:{pwd}".encode()).decode()
|
|
headers["Authorization"] = f"Basic {encoded}"
|
|
elif req.auth_type == "apikey":
|
|
key = req.auth_data.get("key", "")
|
|
value = req.auth_data.get("value", "")
|
|
location = req.auth_data.get("in", "header")
|
|
if location == "header" and key:
|
|
headers[key] = value
|
|
return headers
|
|
|
|
|
|
def send_request(req: HttpRequest, variables: dict = None) -> HttpResponse:
|
|
r = req # will be overwritten with resolved copy; kept here for exception handlers
|
|
try:
|
|
r = apply_variables(req, variables or {})
|
|
|
|
# Check for unresolved variables
|
|
unresolved = re.findall(r"\{\{(.+?)\}\}", r.url)
|
|
if unresolved:
|
|
return HttpResponse(
|
|
error=f"Unresolved variable(s): {', '.join(unresolved)}. "
|
|
"Go to Tools → Environments to define them."
|
|
)
|
|
|
|
headers = {**r.headers, **_build_auth_headers(r)}
|
|
|
|
# Query params (merge URL params dict + API-key-in-query)
|
|
params = r.params.copy()
|
|
if r.auth_type == "apikey" and r.auth_data.get("in") == "query":
|
|
params[r.auth_data.get("key", "")] = r.auth_data.get("value", "")
|
|
|
|
# Build request body
|
|
content = None
|
|
data = None
|
|
files = None
|
|
|
|
if r.body_type == "raw" and r.body:
|
|
content = r.body.encode()
|
|
# Auto Content-Type: honour explicit override, then try to detect JSON
|
|
if r.content_type:
|
|
headers.setdefault("Content-Type", r.content_type)
|
|
elif "Content-Type" not in headers:
|
|
stripped = r.body.lstrip()
|
|
if stripped.startswith(("{", "[")):
|
|
headers["Content-Type"] = "application/json"
|
|
else:
|
|
headers["Content-Type"] = "text/plain"
|
|
|
|
elif r.body_type == "urlencoded" and r.body:
|
|
pairs = {}
|
|
for line in r.body.splitlines():
|
|
if "=" in line:
|
|
k, _, v = line.partition("=")
|
|
pairs[k.strip()] = v.strip()
|
|
data = pairs
|
|
|
|
elif r.body_type == "form-data" and r.body:
|
|
# Expect "key=value" lines; values starting with "@" treated as file paths
|
|
pairs = {}
|
|
for line in r.body.splitlines():
|
|
if "=" in line:
|
|
k, _, v = line.partition("=")
|
|
pairs[k.strip()] = v.strip()
|
|
data = pairs
|
|
|
|
timeout = httpx.Timeout(
|
|
connect=10.0,
|
|
read=float(r.timeout),
|
|
write=float(r.timeout),
|
|
pool=5.0,
|
|
)
|
|
|
|
with httpx.Client(
|
|
follow_redirects=True,
|
|
timeout=timeout,
|
|
verify=r.ssl_verify,
|
|
) as client:
|
|
response = client.request(
|
|
method=r.method,
|
|
url=r.url,
|
|
headers=headers,
|
|
params=params or None,
|
|
content=content,
|
|
data=data,
|
|
files=files,
|
|
)
|
|
body = response.text
|
|
size_bytes = len(response.content)
|
|
return HttpResponse(
|
|
status=response.status_code,
|
|
reason=response.reason_phrase,
|
|
headers=dict(response.headers),
|
|
body=body,
|
|
elapsed_ms=response.elapsed.total_seconds() * 1000,
|
|
size_bytes=size_bytes,
|
|
)
|
|
|
|
except httpx.InvalidURL:
|
|
return HttpResponse(error=f"Invalid URL: {r.url}")
|
|
except httpx.ConnectError as e:
|
|
detail = str(e)
|
|
if "CERTIFICATE_VERIFY_FAILED" in detail or "certificate" in detail.lower() or "SSL" in detail:
|
|
return HttpResponse(error=(
|
|
f"SSL certificate error — could not connect to {r.url}\n\n"
|
|
f"The server's certificate is not trusted or doesn't match the hostname.\n"
|
|
f"Tip: disable SSL verification in the request Settings tab."
|
|
))
|
|
return HttpResponse(error=f"Connection refused — could not reach {r.url}")
|
|
except httpx.ConnectTimeout:
|
|
return HttpResponse(error=f"Connection timed out after {req.timeout}s")
|
|
except httpx.ReadTimeout:
|
|
return HttpResponse(error=f"Read timed out — server took too long to respond")
|
|
except httpx.SSLError as e:
|
|
return HttpResponse(error=f"SSL error: {e}. Disable SSL verification if using a self-signed cert.")
|
|
except httpx.TooManyRedirects:
|
|
return HttpResponse(error="Too many redirects — possible redirect loop")
|
|
except Exception as e:
|
|
return HttpResponse(error=str(e))
|