| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220 |
- """API_PULL 对端 Mock(任务书 P1-D)。
- 契约严格对齐 MdpApiPullExecutor.cs 与既有 doc/db/mdp/mock_api/mock_server.py:
- - GET 单次请求,不分页;响应数组位于 data.list(可切换 responsePath);
- - 每行去重键 bizKey(dedup_key_path);?cursor=<上批最后一行 bizKey> 只返回更大行;
- - 鉴权模式 NONE / TOKEN(Bearer) / BASIC / APIKEY,对齐执行器 ApplyAuth 分支;
- - 可注入延迟与强制 HTTP 错误(401/403/429/500);
- - 端点复用 doc/db/mdp/mock_api/endpoints.json 与 samples/*.json(不迁移、不修改),
- 另支持运行时 override:把模拟器适配后的样例(带 SIM 前缀)挂到任意路径。
- 每次调用写入内存审计(Header 脱敏),Secret 值以 redact_text 二次遮蔽。
- """
- from __future__ import annotations
- import base64
- import json
- import sys
- import time
- from dataclasses import dataclass, field
- from pathlib import Path
- _SIM_ROOT = Path(__file__).resolve().parents[1]
- if str(_SIM_ROOT) not in sys.path:
- sys.path.insert(0, str(_SIM_ROOT))
- from fastapi import FastAPI, HTTPException, Query, Request # noqa: E402
- from catalog.sample_adapters import MOCK_API_DIR # noqa: E402
- from clients.redaction import redact_headers # noqa: E402
- @dataclass
- class MockApiSettings:
- auth_mode: str = "TOKEN" # NONE | TOKEN | BASIC | APIKEY
- token: str = "sim-mock-token"
- basic_user: str = "sim"
- basic_password: str = "sim-basic-password"
- api_key_header: str = "X-Api-Key"
- api_key_value: str = "sim-api-key"
- delay_seconds: float = 0.0
- force_status: int | None = None # 401/403/429/500 等
- response_path: str = "data.list"
- audit_limit: int = 200
- @dataclass
- class MockApiState:
- settings: MockApiSettings = field(default_factory=MockApiSettings)
- overrides: dict[str, list[dict]] = field(default_factory=dict)
- audit: list[dict] = field(default_factory=list)
- def secrets(self) -> list[str]:
- s = self.settings
- return [v for v in (s.token, s.basic_password, s.api_key_value) if v]
- def _load_endpoints() -> dict[str, str]:
- with (MOCK_API_DIR / "endpoints.json").open("r", encoding="utf-8") as f:
- return json.load(f)
- def _load_rows(rel_path: str) -> list[dict]:
- p = MOCK_API_DIR / rel_path
- if not p.exists():
- raise HTTPException(status_code=404, detail=f"sample not found: {rel_path}")
- with p.open("r", encoding="utf-8") as f:
- return json.load(f)
- def _build_response(rows: list[dict], response_path: str) -> dict:
- parts = [p for p in response_path.split(".") if p]
- body: dict = {}
- node = body
- for part in parts[:-1]:
- node[part] = {}
- node = node[part]
- node[parts[-1]] = rows if parts else rows
- body.setdefault("code", 0)
- body.setdefault("message", "ok")
- return body
- def _check_auth(state: MockApiState, request: Request) -> None:
- s = state.settings
- mode = (s.auth_mode or "NONE").upper()
- if mode == "NONE":
- return
- authorization = request.headers.get("Authorization")
- if mode in ("TOKEN", "OAUTH2"):
- if authorization != f"Bearer {s.token}":
- raise HTTPException(status_code=401, detail="invalid or missing bearer token")
- elif mode == "BASIC":
- expected = "Basic " + base64.b64encode(f"{s.basic_user}:{s.basic_password}".encode()).decode()
- if authorization != expected:
- raise HTTPException(status_code=401, detail="invalid or missing basic credentials")
- elif mode == "APIKEY":
- if request.headers.get(s.api_key_header) != s.api_key_value:
- raise HTTPException(status_code=401, detail=f"invalid or missing api key header {s.api_key_header}")
- else:
- raise HTTPException(status_code=401, detail=f"unsupported auth mode on mock side: {mode}")
- def _audit(state: MockApiState, entry: dict) -> None:
- state.audit.append(entry)
- if len(state.audit) > state.settings.audit_limit:
- del state.audit[: len(state.audit) - state.settings.audit_limit]
- def create_app(state: MockApiState | None = None) -> FastAPI:
- state = state or MockApiState()
- app = FastAPI(title="Ai-DOP Integration Simulator - Mock API (API_PULL)", version="1.0.0")
- app.state.sim = state
- @app.get("/health")
- def health():
- return {"status": "UP", "authMode": state.settings.auth_mode}
- @app.get("/__endpoints")
- def list_endpoints():
- return {
- "authMode": state.settings.auth_mode,
- "responsePath": state.settings.response_path,
- "endpoints": _load_endpoints(),
- "overrides": sorted(state.overrides.keys()),
- }
- @app.get("/__control/settings")
- def get_settings():
- s = state.settings
- # 不回显任何凭据值
- return {
- "authMode": s.auth_mode, "delaySeconds": s.delay_seconds,
- "forceStatus": s.force_status, "responsePath": s.response_path,
- "apiKeyHeader": s.api_key_header,
- }
- @app.post("/__control/settings")
- def update_settings(payload: dict):
- s = state.settings
- if "auth_mode" in payload:
- mode = str(payload["auth_mode"]).upper()
- if mode not in ("NONE", "TOKEN", "BASIC", "APIKEY", "OAUTH2"):
- raise HTTPException(status_code=400, detail=f"unknown auth_mode: {mode}")
- s.auth_mode = mode
- if "token" in payload and payload["token"]:
- s.token = str(payload["token"])
- if "basic_user" in payload and payload["basic_user"]:
- s.basic_user = str(payload["basic_user"])
- if "basic_password" in payload and payload["basic_password"]:
- s.basic_password = str(payload["basic_password"])
- if "api_key_header" in payload and payload["api_key_header"]:
- s.api_key_header = str(payload["api_key_header"])
- if "api_key_value" in payload and payload["api_key_value"]:
- s.api_key_value = str(payload["api_key_value"])
- if "delay_seconds" in payload:
- s.delay_seconds = max(0.0, float(payload["delay_seconds"]))
- if "force_status" in payload:
- s.force_status = int(payload["force_status"]) if payload["force_status"] else None
- if "response_path" in payload and payload["response_path"]:
- s.response_path = str(payload["response_path"])
- return get_settings()
- @app.post("/__control/overrides")
- def set_override(payload: dict):
- path = str(payload.get("path") or "")
- rows = payload.get("rows")
- if not path.startswith("/api/") or not isinstance(rows, list):
- raise HTTPException(status_code=400, detail="need {path:/api/..., rows:[...]}")
- state.overrides[path] = rows
- return {"path": path, "rowCount": len(rows)}
- @app.delete("/__control/overrides")
- def clear_overrides():
- state.overrides.clear()
- return {"cleared": True}
- @app.get("/__control/audit")
- def get_audit():
- return {"entries": state.audit[-50:]}
- @app.get("/api/{obj:path}")
- def get_object(
- obj: str,
- request: Request,
- cursor: str | None = Query(default=None, description="上批最后一行 bizKey(增量游标)"),
- ):
- started = time.time()
- path = request.url.path
- if state.settings.delay_seconds > 0:
- time.sleep(state.settings.delay_seconds)
- try:
- if state.settings.force_status:
- raise HTTPException(status_code=state.settings.force_status,
- detail=f"forced error {state.settings.force_status}")
- _check_auth(state, request)
- if path in state.overrides:
- rows = [dict(r) for r in state.overrides[path]]
- else:
- endpoints = _load_endpoints()
- if path not in endpoints:
- raise HTTPException(status_code=404,
- detail=f"no mock for {path}; add it to endpoints.json or set an override")
- rows = _load_rows(endpoints[path])
- rows.sort(key=lambda r: str(r.get("bizKey", "")))
- total = len(rows)
- if cursor:
- rows = [r for r in rows if str(r.get("bizKey", "")) > cursor]
- return _build_response(rows, state.settings.response_path)
- finally:
- _audit(state, {
- "time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
- "path": path,
- "cursor": cursor,
- "authMode": state.settings.auth_mode,
- "forceStatus": state.settings.force_status,
- "elapsedMs": int((time.time() - started) * 1000),
- "headers": redact_headers(dict(request.headers), state.secrets()),
- })
- return app
|