Initial release — APIClient - Agent v2.0.0
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>
This commit is contained in:
161
app/core/http_client.py
Normal file
161
app/core/http_client.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user