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:
467
app/ui/request_panel.py
Normal file
467
app/ui/request_panel.py
Normal file
@@ -0,0 +1,467 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user