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>
614 lines
22 KiB
Python
614 lines
22 KiB
Python
"""EKIKA Odoo API Framework — Direct collection generator.
|
|
|
|
Generates complete Postman-style collections from the EKIKA api_framework module
|
|
without requiring any AI API calls. All URL patterns, body formats, auth headers,
|
|
and special operations are derived directly from the framework documentation.
|
|
|
|
Supported API kinds: JSON-API, REST JSON, GraphQL, Custom REST JSON
|
|
Supported auth types: API Key, Basic Auth, User Credentials, OAuth2, JWT, Public
|
|
"""
|
|
import json
|
|
import re
|
|
|
|
|
|
# ── Constants ─────────────────────────────────────────────────────────────────
|
|
|
|
API_KINDS = ["JSON-API", "REST JSON", "GraphQL", "Custom REST JSON"]
|
|
|
|
AUTH_TYPES = [
|
|
"API Key",
|
|
"Basic Auth",
|
|
"User Credentials",
|
|
"OAuth2",
|
|
"JWT",
|
|
"Public",
|
|
]
|
|
|
|
OPERATIONS = [
|
|
"List Records",
|
|
"Get Single Record",
|
|
"Create Record",
|
|
"Update Record",
|
|
"Delete Record",
|
|
"Execute Method",
|
|
"Export to Excel",
|
|
"Generate Report",
|
|
"Get Fields",
|
|
"Check Access Rights",
|
|
]
|
|
|
|
# Content-Type per API kind
|
|
_CT = {
|
|
"JSON-API": "application/vnd.api+json",
|
|
"REST JSON": "application/json",
|
|
"GraphQL": "application/json",
|
|
"Custom REST JSON": "application/json",
|
|
}
|
|
|
|
|
|
# ── Auth header builder ───────────────────────────────────────────────────────
|
|
|
|
def _auth_headers(auth_type: str) -> dict:
|
|
if auth_type == "API Key":
|
|
return {"x-api-key": "{{api_key}}"}
|
|
if auth_type == "Basic Auth":
|
|
return {"Authorization": "Basic {{base64_user_pass}}"}
|
|
if auth_type == "User Credentials":
|
|
return {"username": "{{username}}", "password": "{{password}}"}
|
|
if auth_type == "OAuth2":
|
|
return {"Authorization": "Bearer {{access_token}}"}
|
|
if auth_type == "JWT":
|
|
return {"Authorization": "Bearer {{jwt_token}}"}
|
|
return {} # Public
|
|
|
|
|
|
def _env_vars(instance_url: str, auth_type: str, extra: dict = None) -> dict:
|
|
base = instance_url.rstrip("/")
|
|
env: dict = {"base_url": base}
|
|
|
|
if auth_type == "API Key":
|
|
env["api_key"] = extra.get("api_key", "") if extra else ""
|
|
elif auth_type == "Basic Auth":
|
|
env["base64_user_pass"] = extra.get("base64_user_pass", "") if extra else ""
|
|
env["username"] = extra.get("username", "") if extra else ""
|
|
env["password"] = extra.get("password", "") if extra else ""
|
|
elif auth_type == "User Credentials":
|
|
env["username"] = extra.get("username", "") if extra else ""
|
|
env["password"] = extra.get("password", "") if extra else ""
|
|
elif auth_type == "OAuth2":
|
|
env["access_token"] = extra.get("access_token", "") if extra else ""
|
|
env["refresh_token"] = ""
|
|
env["client_id"] = ""
|
|
env["client_secret"] = ""
|
|
elif auth_type == "JWT":
|
|
env["jwt_token"] = extra.get("jwt_token", "") if extra else ""
|
|
|
|
return env
|
|
|
|
|
|
def _clean_endpoint(endpoint: str) -> str:
|
|
"""Normalise endpoint slug — ensure leading slash, strip trailing slash."""
|
|
ep = endpoint.strip().strip("/")
|
|
return f"/{ep}" if ep else "/api"
|
|
|
|
|
|
def _model_gql(model: str) -> str:
|
|
"""Convert 'sale.order' → 'sale_order' for GraphQL field names."""
|
|
return model.replace(".", "_")
|
|
|
|
|
|
# ── Request builders ──────────────────────────────────────────────────────────
|
|
|
|
def _jsonapi_test(status: int = 200) -> str:
|
|
return (
|
|
f"pm.test('Status {status}', lambda: pm.response.to_have_status({status}))\n"
|
|
f"pm.test('Has data', lambda: expect(pm.response.json()).to_have_key('data'))"
|
|
)
|
|
|
|
|
|
def _rest_test(status: int = 200) -> str:
|
|
return (
|
|
f"pm.test('Status {status}', lambda: pm.response.to_have_status({status}))\n"
|
|
f"pm.test('Has body', lambda: expect(pm.response.text).to_be_truthy())"
|
|
)
|
|
|
|
|
|
def _build_jsonapi_endpoints(base_ep: str, model: str, headers: dict,
|
|
operations: list[str]) -> list[dict]:
|
|
ct = _CT["JSON-API"]
|
|
ep_path = f"{base_ep}/{model}"
|
|
eps = []
|
|
|
|
if "List Records" in operations:
|
|
eps.append({
|
|
"name": f"List {model}",
|
|
"method": "GET",
|
|
"path": ep_path,
|
|
"headers": {**headers, "Accept": ct},
|
|
"params": {
|
|
"page[number]": "1",
|
|
"page[size]": "10",
|
|
f"fields[{model}]": "id,name,display_name",
|
|
"sort": "id",
|
|
},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _jsonapi_test(200),
|
|
"description": f"Fetch paginated list of {model} records with field selection, sorting and filtering.",
|
|
})
|
|
|
|
if "Get Single Record" in operations:
|
|
eps.append({
|
|
"name": f"Get {model} by ID",
|
|
"method": "GET",
|
|
"path": f"{ep_path}/{{{{id}}}}",
|
|
"headers": {**headers, "Accept": ct},
|
|
"params": {f"fields[{model}]": "id,name,display_name"},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _jsonapi_test(200),
|
|
"description": f"Fetch a single {model} record by its database ID.",
|
|
})
|
|
|
|
if "Create Record" in operations:
|
|
body = json.dumps({
|
|
"data": {
|
|
"type": model,
|
|
"attributes": {"name": f"New {model.split('.')[-1].replace('_', ' ').title()}"},
|
|
}
|
|
}, indent=2)
|
|
eps.append({
|
|
"name": f"Create {model}",
|
|
"method": "POST",
|
|
"path": ep_path,
|
|
"headers": {**headers, "Content-Type": ct, "Accept": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _jsonapi_test(201),
|
|
"description": f"Create a new {model} record.",
|
|
})
|
|
|
|
if "Update Record" in operations:
|
|
body = json.dumps({
|
|
"data": {
|
|
"type": model,
|
|
"id": "{{id}}",
|
|
"attributes": {"name": "Updated Name"},
|
|
}
|
|
}, indent=2)
|
|
eps.append({
|
|
"name": f"Update {model}",
|
|
"method": "PATCH",
|
|
"path": f"{ep_path}/{{{{id}}}}",
|
|
"headers": {**headers, "Content-Type": ct, "Accept": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _jsonapi_test(200),
|
|
"description": f"Update an existing {model} record by ID.",
|
|
})
|
|
|
|
if "Delete Record" in operations:
|
|
eps.append({
|
|
"name": f"Delete {model}",
|
|
"method": "DELETE",
|
|
"path": f"{ep_path}/{{{{id}}}}",
|
|
"headers": {**headers, "Accept": ct},
|
|
"params": {},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _jsonapi_test(200),
|
|
"description": f"Delete a {model} record by ID.",
|
|
})
|
|
|
|
if "Execute Method" in operations:
|
|
body = json.dumps({
|
|
"data": {
|
|
"type": model,
|
|
"id": "{{id}}",
|
|
"attributes": {
|
|
"method": "action_confirm",
|
|
"args": [],
|
|
"kwargs": {},
|
|
},
|
|
}
|
|
}, indent=2)
|
|
eps.append({
|
|
"name": f"Execute Method on {model}",
|
|
"method": "POST",
|
|
"path": f"{ep_path}/{{{{id}}}}/execute",
|
|
"headers": {**headers, "Content-Type": ct, "Accept": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": f"Execute an ORM method on a {model} record. Change 'action_confirm' to any valid method name.",
|
|
})
|
|
|
|
if "Export to Excel" in operations:
|
|
body = json.dumps({
|
|
"data": {
|
|
"type": model,
|
|
"attributes": {
|
|
"fields": ["id", "name", "display_name"],
|
|
"ids": [],
|
|
},
|
|
}
|
|
}, indent=2)
|
|
eps.append({
|
|
"name": f"Export {model} to Excel",
|
|
"method": "POST",
|
|
"path": f"{base_ep}/export",
|
|
"headers": {**headers, "Content-Type": ct, "Accept": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": "Returns a Base64-encoded Excel file with the specified fields.",
|
|
})
|
|
|
|
if "Generate Report" in operations:
|
|
body = json.dumps({
|
|
"data": {
|
|
"type": model,
|
|
"attributes": {
|
|
"report": f"{model.split('.')[0]}.report_{model.split('.')[-1]}",
|
|
"ids": ["{{id}}"],
|
|
"format": "pdf",
|
|
},
|
|
}
|
|
}, indent=2)
|
|
eps.append({
|
|
"name": f"Generate Report for {model}",
|
|
"method": "POST",
|
|
"path": f"{base_ep}/report",
|
|
"headers": {**headers, "Content-Type": ct, "Accept": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": "Generate a PDF/HTML/TEXT report. Change format to 'html' or 'text' as needed.",
|
|
})
|
|
|
|
if "Get Fields" in operations:
|
|
eps.append({
|
|
"name": f"Get Fields — {model}",
|
|
"method": "GET",
|
|
"path": f"{ep_path}/fields_get",
|
|
"headers": {**headers, "Accept": ct},
|
|
"params": {"attributes": "string,type,required,help"},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _rest_test(200),
|
|
"description": f"Get all field definitions for {model}.",
|
|
})
|
|
|
|
if "Check Access Rights" in operations:
|
|
eps.append({
|
|
"name": f"Check Access — {model}",
|
|
"method": "GET",
|
|
"path": f"{ep_path}/check_access_rights",
|
|
"headers": {**headers, "Accept": ct},
|
|
"params": {"operation": "read"},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _rest_test(200),
|
|
"description": "Check if the current user has the specified access right (read/write/create/unlink).",
|
|
})
|
|
|
|
return eps
|
|
|
|
|
|
def _build_restjson_endpoints(base_ep: str, model: str, headers: dict,
|
|
operations: list[str]) -> list[dict]:
|
|
ct = _CT["REST JSON"]
|
|
ep_path = f"{base_ep}/{model}"
|
|
eps = []
|
|
|
|
if "List Records" in operations:
|
|
eps.append({
|
|
"name": f"List {model}",
|
|
"method": "GET",
|
|
"path": ep_path,
|
|
"headers": {**headers, "Accept": ct},
|
|
"params": {"page": "1", "limit": "10", "fields": "id,name,display_name", "sort": "id"},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _rest_test(200),
|
|
"description": f"Fetch paginated list of {model} records.",
|
|
})
|
|
|
|
if "Get Single Record" in operations:
|
|
eps.append({
|
|
"name": f"Get {model} by ID",
|
|
"method": "GET",
|
|
"path": f"{ep_path}/{{{{id}}}}",
|
|
"headers": {**headers, "Accept": ct},
|
|
"params": {"fields": "id,name,display_name"},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _rest_test(200),
|
|
"description": f"Fetch a single {model} record by ID.",
|
|
})
|
|
|
|
if "Create Record" in operations:
|
|
body = json.dumps({"name": f"New {model.split('.')[-1].title()}"}, indent=2)
|
|
eps.append({
|
|
"name": f"Create {model}",
|
|
"method": "POST",
|
|
"path": ep_path,
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(201),
|
|
"description": f"Create a new {model} record.",
|
|
})
|
|
|
|
if "Update Record" in operations:
|
|
body = json.dumps({"name": "Updated Name"}, indent=2)
|
|
eps.append({
|
|
"name": f"Update {model}",
|
|
"method": "PATCH",
|
|
"path": f"{ep_path}/{{{{id}}}}",
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": f"Update an existing {model} record.",
|
|
})
|
|
|
|
if "Delete Record" in operations:
|
|
eps.append({
|
|
"name": f"Delete {model}",
|
|
"method": "DELETE",
|
|
"path": f"{ep_path}/{{{{id}}}}",
|
|
"headers": {**headers},
|
|
"params": {},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _rest_test(200),
|
|
"description": f"Delete a {model} record by ID.",
|
|
})
|
|
|
|
if "Execute Method" in operations:
|
|
body = json.dumps({"method": "action_confirm", "args": [], "kwargs": {}}, indent=2)
|
|
eps.append({
|
|
"name": f"Execute Method on {model}",
|
|
"method": "POST",
|
|
"path": f"{ep_path}/{{{{id}}}}/execute",
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": body,
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": "Execute an ORM method on a record.",
|
|
})
|
|
|
|
if "Get Fields" in operations:
|
|
eps.append({
|
|
"name": f"Get Fields — {model}",
|
|
"method": "GET",
|
|
"path": f"{ep_path}/fields_get",
|
|
"headers": {**headers},
|
|
"params": {},
|
|
"body": "",
|
|
"body_type": "raw",
|
|
"content_type": "",
|
|
"test_script": _rest_test(200),
|
|
"description": f"Get field definitions for {model}.",
|
|
})
|
|
|
|
return eps
|
|
|
|
|
|
def _build_graphql_endpoints(base_ep: str, model: str, headers: dict,
|
|
operations: list[str]) -> list[dict]:
|
|
ct = _CT["GraphQL"]
|
|
gql = _model_gql(model)
|
|
path = f"{base_ep}/graphql"
|
|
eps = []
|
|
|
|
if "List Records" in operations:
|
|
query = (
|
|
f"query {{\n"
|
|
f" {gql}(\n"
|
|
f" filter: \"\"\n"
|
|
f" pageSize: 10\n"
|
|
f" pageNumber: 1\n"
|
|
f" ) {{\n"
|
|
f" id\n"
|
|
f" name\n"
|
|
f" display_name\n"
|
|
f" }}\n"
|
|
f"}}"
|
|
)
|
|
eps.append({
|
|
"name": f"GraphQL — List {model}",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": json.dumps({"query": query}, indent=2),
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": f"GraphQL query to list {model} records.",
|
|
})
|
|
|
|
if "Get Single Record" in operations:
|
|
query = (
|
|
f"query {{\n"
|
|
f" {gql}(id: {{{{id}}}}) {{\n"
|
|
f" id\n"
|
|
f" name\n"
|
|
f" display_name\n"
|
|
f" }}\n"
|
|
f"}}"
|
|
)
|
|
eps.append({
|
|
"name": f"GraphQL — Get {model} by ID",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": json.dumps({"query": query}, indent=2),
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": f"GraphQL query to get a single {model} record.",
|
|
})
|
|
|
|
if "Create Record" in operations:
|
|
mutation = (
|
|
f"mutation {{\n"
|
|
f" create_{gql}(\n"
|
|
f" attributes: {{\n"
|
|
f" name: \"New Record\"\n"
|
|
f" }}\n"
|
|
f" ) {{\n"
|
|
f" id\n"
|
|
f" name\n"
|
|
f" }}\n"
|
|
f"}}"
|
|
)
|
|
eps.append({
|
|
"name": f"GraphQL — Create {model}",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": json.dumps({"query": mutation}, indent=2),
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": f"GraphQL mutation to create a {model} record.",
|
|
})
|
|
|
|
if "Update Record" in operations:
|
|
mutation = (
|
|
f"mutation {{\n"
|
|
f" update_{gql}(\n"
|
|
f" id: {{{{id}}}}\n"
|
|
f" attributes: {{\n"
|
|
f" name: \"Updated Name\"\n"
|
|
f" }}\n"
|
|
f" ) {{\n"
|
|
f" id\n"
|
|
f" name\n"
|
|
f" }}\n"
|
|
f"}}"
|
|
)
|
|
eps.append({
|
|
"name": f"GraphQL — Update {model}",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": json.dumps({"query": mutation}, indent=2),
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": f"GraphQL mutation to update a {model} record.",
|
|
})
|
|
|
|
if "Delete Record" in operations:
|
|
mutation = (
|
|
f"mutation {{\n"
|
|
f" delete_{gql}(id: {{{{id}}}}) {{\n"
|
|
f" id\n"
|
|
f" }}\n"
|
|
f"}}"
|
|
)
|
|
eps.append({
|
|
"name": f"GraphQL — Delete {model}",
|
|
"method": "POST",
|
|
"path": path,
|
|
"headers": {**headers, "Content-Type": ct},
|
|
"params": {},
|
|
"body": json.dumps({"query": mutation}, indent=2),
|
|
"body_type": "raw",
|
|
"content_type": ct,
|
|
"test_script": _rest_test(200),
|
|
"description": f"GraphQL mutation to delete a {model} record.",
|
|
})
|
|
|
|
return eps
|
|
|
|
|
|
# ── Main entry point ──────────────────────────────────────────────────────────
|
|
|
|
def generate_collection(
|
|
instance_url: str,
|
|
endpoint: str,
|
|
api_kind: str,
|
|
auth_type: str,
|
|
auth_creds: dict,
|
|
models: list[str],
|
|
operations: list[str],
|
|
collection_name: str = "",
|
|
) -> dict:
|
|
"""
|
|
Generate an EKIKA API Framework collection dict ready for storage.
|
|
|
|
Returns same structure as ai_client.analyze_docs().
|
|
"""
|
|
base_url = instance_url.rstrip("/")
|
|
base_ep = _clean_endpoint(endpoint)
|
|
headers = _auth_headers(auth_type)
|
|
env_vars = _env_vars(instance_url, auth_type, auth_creds)
|
|
|
|
all_endpoints = []
|
|
for model in models:
|
|
model = model.strip()
|
|
if not model:
|
|
continue
|
|
if api_kind == "JSON-API":
|
|
all_endpoints += _build_jsonapi_endpoints(base_ep, model, headers, operations)
|
|
elif api_kind == "REST JSON":
|
|
all_endpoints += _build_restjson_endpoints(base_ep, model, headers, operations)
|
|
elif api_kind == "GraphQL":
|
|
all_endpoints += _build_graphql_endpoints(base_ep, model, headers, operations)
|
|
else: # Custom REST JSON — same as REST JSON
|
|
all_endpoints += _build_restjson_endpoints(base_ep, model, headers, operations)
|
|
|
|
# Build URLs using {{base_url}} variable
|
|
for ep in all_endpoints:
|
|
if not ep["path"].startswith("http"):
|
|
ep["url"] = f"{{{{base_url}}}}{ep['path']}"
|
|
|
|
name = collection_name or f"EKIKA Odoo — {api_kind} — {', '.join(models[:3])}"
|
|
|
|
return {
|
|
"collection_name": name,
|
|
"base_url": base_url,
|
|
"auth_type": auth_type.lower().replace(" ", "_"),
|
|
"doc_type": "ekika_odoo_framework",
|
|
"endpoints": all_endpoints,
|
|
"environment_variables": env_vars,
|
|
"notes": (
|
|
f"API Kind: {api_kind} | Auth: {auth_type}\n"
|
|
f"Endpoint: {base_url}{base_ep}/{{model}}\n"
|
|
f"Replace {{{{id}}}} with actual record IDs before sending."
|
|
),
|
|
"_source": "ekika_odoo",
|
|
}
|