Files
APIClient-Agent/app/ui/request_panel.py
Anand Shukla 01662f7e0e 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>
2026-03-28 17:38:57 +05:30

468 lines
18 KiB
Python

"""APIClient - Agent — Request Panel."""
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QComboBox, QLineEdit,
QPushButton, QTabWidget, QTableWidget, QTableWidgetItem,
QTextEdit, QHeaderView, QLabel, QFormLayout, QStackedWidget,
QCheckBox, QSpinBox
)
from PyQt6.QtCore import pyqtSignal, Qt
from PyQt6.QtGui import QFont
from app.ui.theme import Colors, method_color
from app.ui.highlighter import JsonHighlighter
from app.models import HttpRequest
HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
class KeyValueTable(QTableWidget):
"""Editable key-value table with enable/disable checkboxes per row."""
def __init__(self, key_hint: str = "Key", val_hint: str = "Value", parent=None):
super().__init__(0, 3, parent)
self.setHorizontalHeaderLabels(["", key_hint, val_hint])
hh = self.horizontalHeader()
hh.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
hh.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
hh.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.setColumnWidth(0, 32)
self.verticalHeader().setVisible(False)
self.verticalHeader().setDefaultSectionSize(36)
self.setAlternatingRowColors(True)
self.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._add_empty_row()
self.itemChanged.connect(self._on_item_changed)
def _make_checkbox_item(self, checked: bool = True) -> QTableWidgetItem:
item = QTableWidgetItem()
item.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled)
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
return item
def _add_empty_row(self):
row = self.rowCount()
self.blockSignals(True)
self.insertRow(row)
self.setItem(row, 0, self._make_checkbox_item(True))
self.setItem(row, 1, QTableWidgetItem(""))
self.setItem(row, 2, QTableWidgetItem(""))
self.blockSignals(False)
def _on_item_changed(self, item):
if item.column() in (1, 2) and item.row() == self.rowCount() - 1 and item.text().strip():
self._add_empty_row()
def get_pairs(self) -> dict:
result = {}
for row in range(self.rowCount()):
chk = self.item(row, 0)
if chk and chk.checkState() == Qt.CheckState.Unchecked:
continue
k = self.item(row, 1)
v = self.item(row, 2)
key = k.text().strip() if k else ""
val = v.text().strip() if v else ""
if key:
result[key] = val
return result
def set_pairs(self, pairs: dict):
self.blockSignals(True)
self.setRowCount(0)
for k, v in pairs.items():
row = self.rowCount()
self.insertRow(row)
self.setItem(row, 0, self._make_checkbox_item(True))
self.setItem(row, 1, QTableWidgetItem(str(k)))
self.setItem(row, 2, QTableWidgetItem(str(v)))
self.blockSignals(False)
self._add_empty_row()
class AuthWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(16, 12, 16, 12)
layout.setSpacing(10)
row = QHBoxLayout()
row.addWidget(QLabel("Auth Type:"))
self.type_combo = QComboBox()
self.type_combo.addItems(["None", "Bearer Token", "Basic Auth", "API Key"])
self.type_combo.setMaximumWidth(200)
self.type_combo.currentIndexChanged.connect(self._on_type)
row.addWidget(self.type_combo)
row.addStretch()
layout.addLayout(row)
self.stack = QStackedWidget()
# None page
none_lbl = QLabel("No authentication configured.")
none_lbl.setObjectName("authNone")
self.stack.addWidget(none_lbl)
# Bearer page
bearer_w = QWidget()
fl = QFormLayout(bearer_w)
fl.setContentsMargins(0, 8, 0, 0)
self.bearer_token = QLineEdit()
self.bearer_token.setPlaceholderText("{{token}}")
self.bearer_token.setEchoMode(QLineEdit.EchoMode.Password)
show_bearer = QCheckBox("Show")
show_bearer.toggled.connect(
lambda on: self.bearer_token.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password
)
)
tok_row = QHBoxLayout()
tok_row.addWidget(self.bearer_token)
tok_row.addWidget(show_bearer)
fl.addRow("Token:", tok_row)
self.stack.addWidget(bearer_w)
# Basic page
basic_w = QWidget()
fl2 = QFormLayout(basic_w)
fl2.setContentsMargins(0, 8, 0, 0)
self.basic_user = QLineEdit()
self.basic_user.setPlaceholderText("username")
self.basic_pass = QLineEdit()
self.basic_pass.setPlaceholderText("password")
self.basic_pass.setEchoMode(QLineEdit.EchoMode.Password)
show_pass = QCheckBox("Show")
show_pass.toggled.connect(
lambda on: self.basic_pass.setEchoMode(
QLineEdit.EchoMode.Normal if on else QLineEdit.EchoMode.Password
)
)
pass_row = QHBoxLayout()
pass_row.addWidget(self.basic_pass)
pass_row.addWidget(show_pass)
fl2.addRow("Username:", self.basic_user)
fl2.addRow("Password:", pass_row)
self.stack.addWidget(basic_w)
# API Key page
apikey_w = QWidget()
fl3 = QFormLayout(apikey_w)
fl3.setContentsMargins(0, 8, 0, 0)
self.apikey_key = QLineEdit()
self.apikey_key.setPlaceholderText("X-API-Key")
self.apikey_value = QLineEdit()
self.apikey_value.setPlaceholderText("{{api_key}}")
self.apikey_in = QComboBox()
self.apikey_in.addItems(["header", "query"])
fl3.addRow("Key:", self.apikey_key)
fl3.addRow("Value:", self.apikey_value)
fl3.addRow("Add to:", self.apikey_in)
self.stack.addWidget(apikey_w)
layout.addWidget(self.stack)
layout.addStretch()
def _on_type(self, idx: int):
self.stack.setCurrentIndex(idx)
def get_auth(self) -> tuple[str, dict]:
idx = self.type_combo.currentIndex()
if idx == 1:
return "bearer", {"token": self.bearer_token.text()}
if idx == 2:
return "basic", {"username": self.basic_user.text(), "password": self.basic_pass.text()}
if idx == 3:
return "apikey", {
"key": self.apikey_key.text(),
"value": self.apikey_value.text(),
"in": self.apikey_in.currentText(),
}
return "none", {}
def set_auth(self, auth_type: str, auth_data: dict):
idx_map = {"none": 0, "bearer": 1, "basic": 2, "apikey": 3}
self.type_combo.setCurrentIndex(idx_map.get(auth_type, 0))
if auth_type == "bearer":
self.bearer_token.setText(auth_data.get("token", ""))
elif auth_type == "basic":
self.basic_user.setText(auth_data.get("username", ""))
self.basic_pass.setText(auth_data.get("password", ""))
elif auth_type == "apikey":
self.apikey_key.setText(auth_data.get("key", ""))
self.apikey_value.setText(auth_data.get("value", ""))
self.apikey_in.setCurrentText(auth_data.get("in", "header"))
def _mono_editor(placeholder: str = "") -> QTextEdit:
e = QTextEdit()
e.setObjectName("codeEditor")
e.setPlaceholderText(placeholder)
e.setAcceptRichText(False)
e.setFont(QFont("JetBrains Mono, Fira Code, Cascadia Code, Consolas, monospace", 11))
return e
class RequestPanel(QWidget):
send_requested = pyqtSignal(object)
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# ── URL bar ──────────────────────────────────────────────────────────
url_bar = QWidget()
url_bar.setObjectName("urlBarStrip")
url_bar.setFixedHeight(56)
url_layout = QHBoxLayout(url_bar)
url_layout.setContentsMargins(12, 0, 12, 0)
url_layout.setSpacing(8)
self.method_combo = QComboBox()
self.method_combo.setObjectName("methodCombo")
self.method_combo.addItems(HTTP_METHODS)
self.method_combo.setFixedWidth(105)
self.method_combo.currentTextChanged.connect(self._on_method_changed)
self.url_input = QLineEdit()
self.url_input.setObjectName("urlBar")
self.url_input.setPlaceholderText("Enter URL — e.g. https://api.example.com/v1/users")
self.url_input.returnPressed.connect(self._send)
self.send_btn = QPushButton("Send")
self.send_btn.setObjectName("sendBtn")
self.send_btn.setFixedWidth(90)
self.send_btn.setToolTip("Send request (Ctrl+Enter)")
self.send_btn.clicked.connect(self._send)
url_layout.addWidget(self.method_combo)
url_layout.addWidget(self.url_input, 1)
url_layout.addWidget(self.send_btn)
layout.addWidget(url_bar)
# ── Request tabs ─────────────────────────────────────────────────────
self.tabs = QTabWidget()
self.tabs.setObjectName("innerTabs")
# Params / Headers
self.params_table = KeyValueTable("Parameter", "Value")
self.headers_table = KeyValueTable("Header", "Value")
# Auth
self.auth_widget = AuthWidget()
# Body tab
body_w = QWidget()
bl = QVBoxLayout(body_w)
bl.setContentsMargins(12, 8, 12, 8)
bl.setSpacing(6)
type_row = QHBoxLayout()
type_row.addWidget(QLabel("Format:"))
self.body_type_combo = QComboBox()
self.body_type_combo.addItems(["raw", "form-urlencoded", "form-data"])
self.body_type_combo.setMaximumWidth(160)
self.body_type_combo.currentTextChanged.connect(self._on_body_type_changed)
type_row.addWidget(self.body_type_combo)
type_row.addSpacing(12)
self.ct_label = QLabel("Content-Type:")
self.ct_label.setObjectName("fieldLabel")
self.ct_combo = QComboBox()
self.ct_combo.addItems([
"application/vnd.api+json",
"application/json",
"application/xml",
"text/plain",
"text/html",
"application/x-www-form-urlencoded",
])
self.ct_combo.setMaximumWidth(230)
type_row.addWidget(self.ct_label)
type_row.addWidget(self.ct_combo)
type_row.addStretch()
fmt_btn = QPushButton("{ } Format")
fmt_btn.setObjectName("ghost")
fmt_btn.setFixedHeight(28)
fmt_btn.setToolTip("Pretty-print / format JSON body (Ctrl+Shift+F)")
fmt_btn.clicked.connect(self._format_body)
type_row.addWidget(fmt_btn)
bl.addLayout(type_row)
self.body_editor = _mono_editor('{\n "key": "value"\n}')
self._body_hl = JsonHighlighter(self.body_editor.document())
bl.addWidget(self.body_editor)
# Pre-request scripts
pre_w = QWidget()
pl = QVBoxLayout(pre_w)
pl.setContentsMargins(12, 8, 12, 8)
hint = QLabel("Python executed before the request. Use pm.environment.get('key') to read variables.")
hint.setObjectName("hintText")
hint.setWordWrap(True)
pl.addWidget(hint)
self.pre_script_editor = _mono_editor("# Example:\n# pm.environment.set('token', 'my-value')")
pl.addWidget(self.pre_script_editor)
# Test scripts
test_w = QWidget()
tl = QVBoxLayout(test_w)
tl.setContentsMargins(12, 8, 12, 8)
hint2 = QLabel("Assertions run automatically after each response is received.")
hint2.setObjectName("hintText")
hint2.setWordWrap(True)
tl.addWidget(hint2)
self.test_editor = _mono_editor(
"pm.test('Status is 200', lambda: pm.response.to_have_status(200))\n"
"pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())"
)
tl.addWidget(self.test_editor)
# Settings tab (timeout, SSL)
settings_filler = QWidget()
sl_outer = QVBoxLayout(settings_filler)
sl_outer.setContentsMargins(0, 0, 0, 0)
sl = QFormLayout()
sl.setContentsMargins(16, 12, 16, 12)
sl.setSpacing(10)
self.timeout_spin = QSpinBox()
self.timeout_spin.setRange(1, 300)
self.timeout_spin.setValue(30)
self.timeout_spin.setSuffix(" s")
self.timeout_spin.setToolTip("Request timeout in seconds")
sl.addRow("Timeout:", self.timeout_spin)
self.ssl_check = QCheckBox("Verify SSL certificate")
self.ssl_check.setChecked(True)
self.ssl_check.setToolTip("Uncheck to allow self-signed or invalid certificates")
sl.addRow("SSL:", self.ssl_check)
sl_outer.addLayout(sl)
sl_outer.addStretch()
self.tabs.addTab(self.params_table, "Params")
self.tabs.addTab(self.headers_table, "Headers")
self.tabs.addTab(self.auth_widget, "Auth")
self.tabs.addTab(body_w, "Body")
self.tabs.addTab(pre_w, "Pre-request")
self.tabs.addTab(test_w, "Tests")
self.tabs.addTab(settings_filler, "Settings")
layout.addWidget(self.tabs, 1)
# Apply initial method color after all widgets are built
self._on_method_changed(self.method_combo.currentText())
# ── Slots ────────────────────────────────────────────────────────────────
def _on_method_changed(self, method: str):
# Inline style is intentional here — color is dynamic per method value
color = method_color(method)
self.method_combo.setStyleSheet(f"QComboBox#methodCombo {{ color: {color}; }}")
def _on_body_type_changed(self, body_type: str):
raw = body_type == "raw"
self.ct_label.setVisible(raw)
self.ct_combo.setVisible(raw)
def _format_body(self):
import json
text = self.body_editor.toPlainText().strip()
if not text:
return
try:
parsed = json.loads(text)
self.body_editor.setPlainText(json.dumps(parsed, indent=2, ensure_ascii=False))
except json.JSONDecodeError:
pass # not valid JSON — leave as-is
def _send(self):
self.send_requested.emit(self._build_request())
# ── Public API ────────────────────────────────────────────────────────────
def get_request(self) -> HttpRequest:
return self._build_request()
def _build_request(self) -> HttpRequest:
auth_type, auth_data = self.auth_widget.get_auth()
body_type = self.body_type_combo.currentText()
content_type = self.ct_combo.currentText() if body_type == "raw" else ""
return HttpRequest(
method = self.method_combo.currentText(),
url = self.url_input.text().strip(),
headers = self.headers_table.get_pairs(),
params = self.params_table.get_pairs(),
body = self.body_editor.toPlainText(),
body_type = body_type,
content_type = content_type,
auth_type = auth_type,
auth_data = auth_data,
pre_request_script = self.pre_script_editor.toPlainText(),
test_script = self.test_editor.toPlainText(),
timeout = self.timeout_spin.value(),
ssl_verify = self.ssl_check.isChecked(),
)
def load_request(self, req: HttpRequest):
idx = self.method_combo.findText(req.method)
if idx >= 0:
self.method_combo.setCurrentIndex(idx)
self.url_input.setText(req.url)
self.headers_table.set_pairs(req.headers or {})
self.params_table.set_pairs(req.params or {})
self.body_editor.setPlainText(req.body or "")
self.body_type_combo.setCurrentText(req.body_type or "raw")
if req.content_type:
idx_ct = self.ct_combo.findText(req.content_type)
if idx_ct >= 0:
self.ct_combo.setCurrentIndex(idx_ct)
self.auth_widget.set_auth(req.auth_type or "none", req.auth_data or {})
self.pre_script_editor.setPlainText(req.pre_request_script or "")
self.test_editor.setPlainText(req.test_script or "")
self.timeout_spin.setValue(req.timeout or 30)
self.ssl_check.setChecked(req.ssl_verify if req.ssl_verify is not None else True)
def apply_body(self, content: str):
"""Set body from AI suggestion and switch to Body tab."""
self.body_editor.setPlainText(content)
self.tabs.setCurrentIndex(3) # Body tab
def apply_params(self, content: str):
"""Parse key=value lines and merge into params table."""
pairs = {}
for line in content.splitlines():
line = line.strip()
if "=" in line and not line.startswith("#"):
k, _, v = line.partition("=")
if k.strip():
pairs[k.strip()] = v.strip()
if pairs:
existing = self.params_table.get_pairs()
existing.update(pairs)
self.params_table.set_pairs(existing)
self.tabs.setCurrentIndex(0) # Params tab
def apply_headers(self, content: str):
"""Parse Header: value lines and merge into headers table."""
pairs = {}
for line in content.splitlines():
line = line.strip()
if ":" in line and not line.startswith("#"):
k, _, v = line.partition(":")
if k.strip():
pairs[k.strip()] = v.strip()
if pairs:
existing = self.headers_table.get_pairs()
existing.update(pairs)
self.headers_table.set_pairs(existing)
self.tabs.setCurrentIndex(1) # Headers tab
def apply_test_script(self, content: str):
"""Set test script from AI suggestion and switch to Tests tab."""
self.test_editor.setPlainText(content)
self.tabs.setCurrentIndex(5) # Tests tab