quality-inspection-platform/app/services/engine.py
qihongkun d810abdcee 初始化仓库:AI 接口自动化测试平台
纳入 FastAPI 后端、Vue 管理端、MCP 桥接与文档;通过 .gitignore 排除本地数据库与构建产物。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-22 15:44:50 +08:00

954 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import json
import re
import time
from collections import defaultdict, deque
from collections.abc import Callable
from typing import Any
import httpx
def render_template_value(value: Any, variables: dict[str, Any]) -> Any:
if isinstance(value, str):
matches = re.findall(r"\{\{(.*?)\}\}", value)
rendered = value
for raw_key in matches:
key = raw_key.strip()
replacement = variables.get(key, "")
rendered = rendered.replace(f"{{{{{raw_key}}}}}", str(replacement))
return rendered
if isinstance(value, dict):
return {k: render_template_value(v, variables) for k, v in value.items()}
if isinstance(value, list):
return [render_template_value(v, variables) for v in value]
return value
def build_execution_order(nodes: list[dict[str, Any]], edges: list[dict[str, Any]]) -> list[str]:
node_ids = [n["id"] for n in nodes]
indegree = {node_id: 0 for node_id in node_ids}
graph = defaultdict(list)
for edge in edges:
source = edge.get("source")
target = edge.get("target")
if source in indegree and target in indegree:
graph[source].append(target)
indegree[target] += 1
queue = deque([node_id for node_id in node_ids if indegree[node_id] == 0])
order = []
while queue:
cur = queue.popleft()
order.append(cur)
for nxt in graph[cur]:
indegree[nxt] -= 1
if indegree[nxt] == 0:
queue.append(nxt)
if len(order) != len(node_ids):
return node_ids
return order
LOOP_BODY_BRANCHES = {"", "body", "loop", "in", "next", "continue"}
LOOP_EXIT_BRANCHES = {"", "done", "break", "out", "exit", "default"}
def _safe_eval(left: Any, op: str, right: Any) -> bool:
if op == "==":
return left == right
if op == "!=":
return left != right
if op == ">":
return left > right
if op == "<":
return left < right
if op == ">=":
return left >= right
if op == "<=":
return left <= right
return False
async def execute_workflow(
definition: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
fail_fast: bool = True,
default_headers: dict[str, Any] | None = None,
on_node_start: Callable[[dict[str, Any], int, int], Any] | None = None,
on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None = None,
) -> dict[str, Any]:
nodes = definition.get("nodes", [])
edges = definition.get("edges", [])
variables = dict(definition.get("variables", {}))
node_map = {node["id"]: node for node in nodes}
order = build_execution_order(nodes, edges)
order_index = {node_id: idx for idx, node_id in enumerate(order)}
results: list[dict[str, Any]] = []
halted = False
graph = _build_edge_graph(edges)
pending = deque(_sorted_start_nodes(nodes, edges, order_index))
queued = set(pending)
executed = set()
total_nodes = len(order)
default_headers = dict(default_headers or {})
async with httpx.AsyncClient(timeout=20.0) as client:
while pending:
node_id = pending.popleft()
queued.discard(node_id)
if node_id in executed or node_id not in node_map:
continue
node = node_map[node_id]
node_type = str(node.get("data", {}).get("type", "http")).lower()
await _invoke_callback(on_node_start, node, len(executed) + 1, total_nodes)
if node_type == "loop":
loop_results, result = await _execute_loop_node(
node=node,
node_map=node_map,
graph=graph,
edges=edges,
order_index=order_index,
variables=variables,
api_map=api_map,
mock_map=mock_map,
base_url=base_url,
client=client,
default_headers=default_headers,
fail_fast=fail_fast,
on_node_start=on_node_start,
on_node_finish=on_node_finish,
executed_count=len(executed),
total_nodes=total_nodes,
)
results.extend(loop_results)
else:
result = await execute_single_node(
node, variables, api_map, mock_map, base_url, client, default_headers=default_headers
)
results.append(result)
executed.add(node_id)
_persist_node_last_run(node, result)
await _invoke_callback(on_node_finish, node, result, len(executed), total_nodes)
if result["status"] == "failed" and fail_fast:
halted = True
break
allowed = _allowed_branches(result)
next_nodes = []
for target, branch in graph.get(node_id, []):
if target in executed or target in queued:
continue
if node_type == "loop":
if branch not in LOOP_EXIT_BRANCHES:
continue
if branch in allowed:
next_nodes.append(target)
next_nodes.sort(key=lambda item: order_index.get(item, 10**9))
for target in next_nodes:
pending.append(target)
queued.add(target)
if halted:
for node_id in order:
if node_id in executed:
continue
node_type = node_map[node_id].get("data", {}).get("type", "http")
skipped_result = {
"node_id": node_id,
"node_type": node_type,
"status": "skipped",
"output": {},
"error": "halted by previous failure",
}
results.append(skipped_result)
_persist_node_last_run(node_map[node_id], skipped_result)
await _invoke_callback(on_node_finish, node_map[node_id], skipped_result, len(executed), total_nodes)
status = "failed" if any(item["status"] == "failed" for item in results) else "success"
return {"status": status, "variables": variables, "results": results, "raw": json.dumps(results)}
async def _invoke_callback(callback: Callable[..., Any] | None, *args: Any) -> None:
if callback is None:
return
result = callback(*args)
if asyncio.iscoroutine(result):
await result
def _persist_node_last_run(node: dict[str, Any], result: dict[str, Any]) -> None:
if not isinstance(node, dict):
return
data = node.setdefault("data", {})
if not isinstance(data, dict):
return
data["last_run"] = {
"status": result.get("status"),
"error": result.get("error"),
"output": result.get("output", {}),
"ts": int(time.time()),
}
def _edge_branch(edge: dict[str, Any]) -> str:
data = edge.get("data")
if isinstance(data, dict):
value = str(data.get("branch", "")).strip().lower()
if value:
return value
label = str(edge.get("label", "")).strip().lower()
return label
def _build_edge_graph(edges: list[dict[str, Any]]) -> dict[str, list[tuple[str, str]]]:
graph: dict[str, list[tuple[str, str]]] = defaultdict(list)
for edge in edges:
source = edge.get("source")
target = edge.get("target")
if not source or not target:
continue
graph[source].append((target, _edge_branch(edge)))
return graph
def _sorted_start_nodes(
nodes: list[dict[str, Any]], edges: list[dict[str, Any]], order_index: dict[str, int]
) -> list[str]:
indegree = {node["id"]: 0 for node in nodes if "id" in node}
for edge in edges:
target = edge.get("target")
if target in indegree:
indegree[target] += 1
starts = [node_id for node_id, degree in indegree.items() if degree == 0]
starts.sort(key=lambda item: order_index.get(item, 10**9))
return starts
def _allowed_branches(result: dict[str, Any]) -> set[str]:
status = result.get("status")
node_type = result.get("node_type")
output = result.get("output", {})
if status == "failed":
return {"", "always", "default", "fail", "failed", "failure", "on_fail"}
if node_type == "condition":
if bool(output.get("result")):
return {"", "always", "default", "true", "on_true", "success"}
return {"", "always", "default", "false", "on_false"}
if node_type == "loop":
return set(LOOP_EXIT_BRANCHES) | {"always", "success", "on_success"}
return {"", "always", "default", "success", "on_success"}
def _resolve_field_path(source: Any, field: str) -> Any:
extracted = source
for part in str(field or "").split("."):
if not part:
continue
if isinstance(extracted, dict):
extracted = extracted.get(part)
else:
return None
return extracted
def _resolve_condition_operand(node_data: dict[str, Any], side: str, variables: dict[str, Any]) -> Any:
mode_key = f"{side}_mode"
mode = str(node_data.get(mode_key) or node_data.get("mode", "template")).strip().lower()
if mode == "json_path":
source_var = str(node_data.get(f"{side}_source_var") or node_data.get("source_var") or "").strip()
field = str(node_data.get(f"{side}_field") or node_data.get("field") or "").strip()
if not source_var:
return None
return _resolve_field_path(variables.get(source_var), field)
raw = node_data.get(side, "")
return render_template_value(raw, variables)
def _evaluate_condition(node_data: dict[str, Any], variables: dict[str, Any]) -> bool:
left = _resolve_condition_operand(node_data, "left", variables)
right = _resolve_condition_operand(node_data, "right", variables)
op = str(node_data.get("op", "=="))
return _safe_eval(left, op, right)
def _collect_loop_body_node_ids(loop_id: str, graph: dict[str, list[tuple[str, str]]]) -> set[str]:
entries = [target for target, branch in graph.get(loop_id, []) if branch in LOOP_BODY_BRANCHES]
if not entries:
return set()
body: set[str] = set()
queue = deque(entries)
while queue:
current = queue.popleft()
if current == loop_id or current in body:
continue
body.add(current)
for target, _branch in graph.get(current, []):
if target != loop_id and target not in body:
queue.append(target)
return body
async def _execute_node_subgraph(
*,
node_ids: set[str],
entry_node_ids: list[str],
node_map: dict[str, dict[str, Any]],
graph: dict[str, list[tuple[str, str]]],
edges: list[dict[str, Any]],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient,
default_headers: dict[str, Any],
fail_fast: bool,
iteration: int,
on_node_start: Callable[[dict[str, Any], int, int], Any] | None,
on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None,
executed_count: int,
total_nodes: int,
) -> tuple[list[dict[str, Any]], bool]:
if not node_ids:
return [], False
subgraph_nodes = [node_map[node_id] for node_id in node_ids if node_id in node_map]
subgraph_edges = [
edge
for edge in edges
if edge.get("source") in node_ids and edge.get("target") in node_ids
]
order = build_execution_order(subgraph_nodes, subgraph_edges)
order_index = {node_id: idx for idx, node_id in enumerate(order)}
indegree = {node_id: 0 for node_id in node_ids}
for edge in subgraph_edges:
target = edge.get("target")
if target in indegree:
indegree[target] += 1
starts = [node_id for node_id in entry_node_ids if node_id in node_ids]
if not starts:
starts = [node_id for node_id, degree in indegree.items() if degree == 0]
starts.sort(key=lambda item: order_index.get(item, 10**9))
pending = deque(starts)
queued = set(starts)
local_executed: set[str] = set()
results: list[dict[str, Any]] = []
halted = False
while pending:
node_id = pending.popleft()
queued.discard(node_id)
if node_id in local_executed or node_id not in node_map:
continue
node = node_map[node_id]
await _invoke_callback(on_node_start, node, executed_count + len(local_executed) + 1, total_nodes)
result = await execute_single_node(
node, variables, api_map, mock_map, base_url, client, default_headers=default_headers
)
if isinstance(result.get("output"), dict):
result["output"]["loop_iteration"] = iteration
results.append(result)
local_executed.add(node_id)
_persist_node_last_run(node, result)
await _invoke_callback(on_node_finish, node, result, executed_count + len(local_executed), total_nodes)
if result["status"] == "failed" and fail_fast:
halted = True
break
allowed = _allowed_branches(result)
next_nodes = []
for target, branch in graph.get(node_id, []):
if target not in node_ids or target in local_executed or target in queued:
continue
if branch in allowed:
next_nodes.append(target)
next_nodes.sort(key=lambda item: order_index.get(item, 10**9))
for target in next_nodes:
pending.append(target)
queued.add(target)
return results, halted
async def _execute_loop_node(
*,
node: dict[str, Any],
node_map: dict[str, dict[str, Any]],
graph: dict[str, list[tuple[str, str]]],
edges: list[dict[str, Any]],
order_index: dict[str, int],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient,
default_headers: dict[str, Any],
fail_fast: bool,
on_node_start: Callable[[dict[str, Any], int, int], Any] | None,
on_node_finish: Callable[[dict[str, Any], dict[str, Any], int, int], Any] | None,
executed_count: int,
total_nodes: int,
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
node_id = str(node.get("id", ""))
node_data = node.get("data", {}) if isinstance(node.get("data"), dict) else {}
max_iterations = max(1, min(int(node_data.get("max_iterations", 10) or 10), 1000))
iteration_var = str(node_data.get("iteration_var") or "loop_index").strip() or "loop_index"
body_node_ids = _collect_loop_body_node_ids(node_id, graph)
entry_node_ids = [
target for target, branch in graph.get(node_id, []) if branch in LOOP_BODY_BRANCHES and target in body_node_ids
]
entry_node_ids.sort(key=lambda item: order_index.get(item, 10**9))
if not body_node_ids:
return [], {
"node_id": node_id,
"node_type": "loop",
"status": "failed",
"output": {"iterations": 0, "reason": "missing loop body branch"},
"error": "循环节点需要连接 body 分支到循环体",
}
all_results: list[dict[str, Any]] = []
iterations_run = 0
continue_loop = True
last_error: str | None = None
while continue_loop and iterations_run < max_iterations:
variables[iteration_var] = iterations_run
body_results, halted = await _execute_node_subgraph(
node_ids=body_node_ids,
entry_node_ids=entry_node_ids,
node_map=node_map,
graph=graph,
edges=edges,
variables=variables,
api_map=api_map,
mock_map=mock_map,
base_url=base_url,
client=client,
default_headers=default_headers,
fail_fast=fail_fast,
iteration=iterations_run,
on_node_start=on_node_start,
on_node_finish=on_node_finish,
executed_count=executed_count + len(all_results),
total_nodes=total_nodes,
)
all_results.extend(body_results)
iterations_run += 1
if halted or any(item.get("status") == "failed" for item in body_results):
last_error = next(
(str(item.get("error") or "loop body failed") for item in body_results if item.get("status") == "failed"),
"loop body failed",
)
break
while_cfg = {
"left_mode": node_data.get("while_left_mode") or node_data.get("left_mode"),
"left": node_data.get("while_left", ""),
"left_source_var": node_data.get("while_left_source_var") or node_data.get("left_source_var"),
"left_field": node_data.get("while_left_field") or node_data.get("left_field"),
"op": node_data.get("while_op", node_data.get("op", "==")),
"right_mode": node_data.get("while_right_mode") or node_data.get("right_mode"),
"right": node_data.get("while_right", ""),
"right_source_var": node_data.get("while_right_source_var"),
"right_field": node_data.get("while_right_field"),
}
has_while_rule = any(
str(while_cfg.get(key) or "").strip()
for key in ("left", "left_source_var", "left_field", "right", "right_source_var", "right_field")
)
if not has_while_rule:
continue_loop = False
else:
continue_loop = _evaluate_condition(while_cfg, variables)
save_as = str(node_data.get("save_as") or f"{node_id}_loop").strip()
summary = {
"iterations": iterations_run,
"max_iterations": max_iterations,
"continue_loop": continue_loop,
"body_nodes": sorted(body_node_ids),
}
variables[save_as] = summary
status = "failed" if last_error else "success"
return all_results, {
"node_id": node_id,
"node_type": "loop",
"status": status,
"output": summary,
"error": last_error,
}
async def execute_single_node(
node: dict[str, Any],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient | None = None,
default_headers: dict[str, Any] | None = None,
) -> dict[str, Any]:
node_id = node.get("id", "")
node_data = node.get("data", {})
node_type = node_data.get("type", "http")
own_client = client is None
if own_client:
client = httpx.AsyncClient(timeout=20.0)
assert client is not None
try:
# Legacy compatibility: 旧的 mock 节点已废弃 → 视为 http
if node_type == "mock":
node_type = "http"
node_data["type"] = "http"
if node_type == "http":
return await _execute_http_node(
node_id=node_id,
node_data=node_data,
variables=variables,
api_map=api_map,
mock_map=mock_map,
base_url=base_url,
client=client,
default_headers=default_headers or {},
)
if node_type == "condition":
ok = _evaluate_condition(node_data, variables)
variables[node_data.get("save_as", f"{node_id}_result")] = ok
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {
"result": ok,
"left": _resolve_condition_operand(node_data, "left", variables),
"right": _resolve_condition_operand(node_data, "right", variables),
"op": node_data.get("op", "=="),
},
"error": None,
}
if node_type == "loop":
return {
"node_id": node_id,
"node_type": node_type,
"status": "failed",
"output": {},
"error": "loop node must be executed by execute_workflow",
}
if node_type in {"start", "end"}:
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"pass": True},
"error": None,
}
if node_type == "extract":
source_key = node_data.get("source_var", "")
field = node_data.get("field", "")
save_as = node_data.get("save_as", "")
source = variables.get(source_key, {})
extracted = source
for part in field.split("."):
if not part:
continue
if isinstance(extracted, dict):
extracted = extracted.get(part)
else:
extracted = None
break
variables[save_as] = extracted
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"value": extracted},
"error": None,
}
if node_type == "transform":
template = node_data.get("template", {})
transformed = render_template_value(template, variables)
save_as = node_data.get("save_as", f"{node_id}_output")
variables[save_as] = transformed
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"value": transformed},
"error": None,
}
if node_type == "delay":
seconds = float(node_data.get("seconds", 1))
await asyncio.sleep(seconds)
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": {"waited": seconds},
"error": None,
}
if node_type == "ai":
prompt = node_data.get("prompt", "")
response = {"message": "AI node reserved", "prompt": prompt}
save_as = node_data.get("save_as", f"{node_id}_ai")
variables[save_as] = response
return {
"node_id": node_id,
"node_type": node_type,
"status": "success",
"output": response,
"error": None,
}
raise ValueError(f"unsupported node type: {node_type}")
except Exception as exc:
return {
"node_id": node_id,
"node_type": node_type,
"status": "failed",
"output": {},
"error": str(exc),
}
finally:
if own_client:
await client.aclose()
def _merge_str_dict(a: dict[str, Any] | None, b: dict[str, Any] | None) -> dict[str, Any]:
out = dict(a or {})
for k, v in (b or {}).items():
out[k] = v
return out
def _mock_payload_for_overlay(
node_data: dict[str, Any], mock_map: dict[str, dict[str, Any]], variables: dict[str, Any]
) -> dict[str, Any]:
raw: Any = node_data.get("mock_data")
if raw is None:
raw = node_data.get("mock")
if isinstance(raw, str) and raw in mock_map:
raw = mock_map.get(raw) or {}
name = node_data.get("mock_name")
if (not raw or raw == {}) and isinstance(name, str) and name.strip() and name in mock_map:
raw = mock_map.get(name) or {}
rendered = render_template_value(raw if isinstance(raw, dict) else {}, variables)
return rendered if isinstance(rendered, dict) else {}
def _looks_like_response_mock(m: dict[str, Any]) -> bool:
if not m:
return False
if "status_code" in m:
return True
if m.get("text") is not None or m.get("json") is not None:
if not any(k in m for k in ("method", "url", "query", "path_params")):
return True
return False
def _has_request_overlay_keys(m: dict[str, Any]) -> bool:
return any(k in m for k in ("method", "url", "headers", "query", "body", "path_params"))
def _apply_request_overlay(
method: str,
url: str,
headers: dict[str, Any],
params: dict[str, Any],
json_body: dict[str, Any],
path_params: dict[str, Any],
overlay: dict[str, Any],
) -> tuple[str, str, dict[str, Any], dict[str, Any], dict[str, Any], dict[str, Any]]:
if not overlay:
return method, url, headers, params, json_body, path_params
if overlay.get("method"):
method = str(overlay["method"]).upper()
if "url" in overlay and overlay["url"] is not None:
url = str(overlay["url"])
if isinstance(overlay.get("headers"), dict):
headers = _merge_str_dict(headers, overlay["headers"])
if isinstance(overlay.get("query"), dict):
params = dict(overlay["query"])
if isinstance(overlay.get("body"), dict):
json_body = dict(overlay["body"])
if isinstance(overlay.get("path_params"), dict):
path_params = dict(overlay["path_params"])
return method, url, headers, params, json_body, path_params
async def _execute_http_node(
node_id: str,
node_data: dict[str, Any],
variables: dict[str, Any],
api_map: dict[int, dict[str, Any]],
mock_map: dict[str, dict[str, Any]],
base_url: str,
client: httpx.AsyncClient,
default_headers: dict[str, Any],
) -> dict[str, Any]:
request_cfg = _resolve_http_request(node_data, api_map)
mode = str(node_data.get("mode", "http")).lower()
overlay_cfg = _mock_payload_for_overlay(node_data, mock_map, variables)
use_request_mock = bool(node_data.get("use_mock"))
legacy_mode_mock = mode == "mock"
# 旧 mode=mock若 overlay 中含请求字段则作为请求 mock仅有响应字段时不覆盖请求仍发真实 HTTP
if legacy_mode_mock:
use_request_mock = use_request_mock or _has_request_overlay_keys(overlay_cfg)
method = str(request_cfg.get("method", "GET")).upper()
url = render_template_value(request_cfg.get("url", ""), variables)
dh = render_template_value(dict(default_headers or {}), variables) or {}
base_h = render_template_value(request_cfg.get("headers", {}), variables) or {}
headers = _merge_str_dict(dh, base_h)
params = render_template_value(request_cfg.get("query", {}), variables) or {}
json_body = render_template_value(request_cfg.get("body", {}), variables) or {}
path_params = render_template_value(request_cfg.get("path_params", {}), variables) or {}
mock_request_applied = False
if use_request_mock and overlay_cfg and _has_request_overlay_keys(overlay_cfg):
method, url, headers, params, json_body, path_params = _apply_request_overlay(
method, url, headers, params, json_body, path_params, overlay_cfg
)
mock_request_applied = True
if base_url and url and not url.startswith("http"):
url = f"{base_url.rstrip('/')}/{url.lstrip('/')}"
for key, value in path_params.items():
url = url.replace(f"{{{key}}}", str(value))
request_payload = {
"method": method,
"url": url,
"headers": headers,
"query": params,
"body": json_body if method in {"POST", "PUT", "PATCH", "DELETE"} else None,
"path_params": path_params,
"mock_request_applied": mock_request_applied,
}
response_payload: dict[str, Any] = {}
error_message: str | None = None
mock_response_used = False
started = time.perf_counter()
try:
response = await client.request(
method=method,
url=url,
headers=headers,
params=params,
json=json_body if method in {"POST", "PUT", "PATCH", "DELETE"} else None,
)
response_payload = _build_response_payload(response)
except Exception as exc:
error_message = str(exc)
if mode == "auto" and overlay_cfg and _looks_like_response_mock(overlay_cfg):
response_payload = _normalize_mock_response(overlay_cfg)
response_payload["mock_fallback_reason"] = error_message
mock_response_used = True
error_message = None
duration_ms = int((time.perf_counter() - started) * 1000)
assertions = _evaluate_http_assertions(node_data.get("expect"), response_payload, error_message)
status = "failed" if (error_message or any(item["passed"] is False for item in assertions)) else "success"
output = {
"request": request_payload,
"response": response_payload,
"mock_used": mock_request_applied or mock_response_used,
"duration_ms": duration_ms,
"assertions": assertions,
}
save_as = node_data.get("save_as")
if save_as:
variables[save_as] = response_payload
if status == "failed" and error_message is None:
error_message = "; ".join(item.get("reason", "assertion failed") for item in assertions if item["passed"] is False)
return {
"node_id": node_id,
"node_type": "http",
"status": status,
"output": output,
"error": error_message,
}
def _resolve_http_request(node_data: dict[str, Any], api_map: dict[int, dict[str, Any]]) -> dict[str, Any]:
"""Merge api_map definition (if api_id given) with inline node fields. Inline overrides."""
base: dict[str, Any] = {}
api_id_value = node_data.get("api_id")
if api_id_value is not None:
try:
api_id = int(api_id_value)
except (TypeError, ValueError):
api_id = None
if api_id is not None and api_id in api_map:
api_def = api_map[api_id]
base = {
"method": api_def.get("method", "GET"),
"url": api_def.get("url", ""),
"headers": dict(api_def.get("headers", {}) or {}),
"query": dict(api_def.get("query", {}) or {}),
"body": dict(api_def.get("body", {}) or {}),
"path_params": dict(api_def.get("path_params", {}) or {}),
}
for field in ("method", "url"):
if node_data.get(field):
base[field] = node_data[field]
for field in ("headers", "query", "body", "path_params"):
if isinstance(node_data.get(field), dict):
merged = dict(base.get(field, {}) or {})
merged.update(node_data[field])
base[field] = merged
base.setdefault("method", "GET")
base.setdefault("url", "")
base.setdefault("headers", {})
base.setdefault("query", {})
base.setdefault("body", {})
base.setdefault("path_params", {})
return base
def _build_response_payload(response: httpx.Response) -> dict[str, Any]:
payload: dict[str, Any] = {
"status_code": response.status_code,
"headers": dict(response.headers),
"text": response.text,
}
try:
payload["json"] = response.json()
except Exception:
payload["json"] = None
return payload
def _normalize_mock_response(mock_data: dict[str, Any]) -> dict[str, Any]:
if not isinstance(mock_data, dict):
return {"status_code": 200, "headers": {}, "text": "", "json": None}
body = mock_data.get("json")
if body is None and "data" in mock_data:
body = mock_data.get("data")
text = mock_data.get("text")
if text is None and body is not None:
try:
text = json.dumps(body, ensure_ascii=False)
except Exception:
text = str(body)
return {
"status_code": int(mock_data.get("status_code", 200)),
"headers": dict(mock_data.get("headers", {}) or {}),
"text": text or "",
"json": body,
"is_mock": True,
}
def _evaluate_http_assertions(
expect: Any, response_payload: dict[str, Any], error_message: str | None
) -> list[dict[str, Any]]:
if not isinstance(expect, dict):
return []
if error_message:
return [{"name": "request", "passed": False, "reason": error_message}]
results: list[dict[str, Any]] = []
status_in = expect.get("status_in") or expect.get("status_codes")
if isinstance(status_in, list) and status_in:
actual = response_payload.get("status_code")
passed = actual in status_in
results.append(
{
"name": "status_in",
"passed": passed,
"expected": status_in,
"actual": actual,
"reason": None if passed else f"status_code {actual} not in {status_in}",
}
)
json_contains = expect.get("json_contains")
if isinstance(json_contains, dict):
body = response_payload.get("json") or {}
passed, reason = _dict_contains(body, json_contains)
results.append(
{
"name": "json_contains",
"passed": passed,
"expected": json_contains,
"actual": body,
"reason": None if passed else reason,
}
)
text_includes = expect.get("text_includes")
if isinstance(text_includes, str):
text = response_payload.get("text") or ""
passed = text_includes in text
results.append(
{
"name": "text_includes",
"passed": passed,
"expected": text_includes,
"actual": text[:200],
"reason": None if passed else f"response text does not contain '{text_includes}'",
}
)
return results
def _dict_contains(actual: Any, expected: Any) -> tuple[bool, str | None]:
if isinstance(expected, dict):
if not isinstance(actual, dict):
return False, "expected object"
for key, value in expected.items():
if key not in actual:
return False, f"missing key: {key}"
ok, reason = _dict_contains(actual[key], value)
if not ok:
return False, f"{key}: {reason}"
return True, None
if isinstance(expected, list):
if not isinstance(actual, list):
return False, "expected list"
for item in expected:
if item not in actual:
return False, f"missing list item: {item}"
return True, None
return (actual == expected, None if actual == expected else f"expected {expected}, got {actual}")