#!/usr/bin/env python3 """ZAA_ICF (ZCL_ZAA_AGENT_API) 호출 클라이언트. MCP 서버(mcp_server.py)와 웹 테스터(../abap-api-tester/server.py)가 공유한다. - 표준 라이브러리만 사용 (Python 3.10+) - 접속정보: .env 파일 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS / SAP_VERIFY_SSL 호출 규약 (실측, README 참고): POST {SAP_URL}/{METHOD}, Content-Type: application/json 2026-09-14 부터 JSON: 바디는 래퍼 없이 {"IV_PACKAGE": "ZFI01", "IV_MAX_ROWS": 100} (키 대문자, 숫자는 JSON 숫자, 플래그는 "X"). 응답도 JSON {"RETURN": {...}, "RESULT": [...]} (TOTAL_ROWS 는 숫자, 빈 목록은 []). 구버전(asXML, 바디 {"INPUT": {...}}) 서버도 응답 첫 글자로 판별해 그대로 읽는다. """ import base64 import json import os import re import ssl import time import urllib.error import urllib.request import xml.etree.ElementTree as ET from pathlib import Path try: # abap-indexing: 패키지(sap.sap_client)로도, 스크립트(mcp_server.py 옆)로도 import 되게 from .catalog import LIST_FIELDS except ImportError: from catalog import LIST_FIELDS ROOT = Path(__file__).resolve().parent DEFAULT_URL = "https://pwcs4h.pwcc.co.kr:44310/sap/bc/ZAA_ICF" def load_env(): # abap-indexing: sap/.env 다음에 저장소 루트 .env 도 본다 (LLM 키와 같은 파일에 SAP_* 를 둔다) for p in (ROOT / ".env", ROOT.parent / ".env"): if not p.exists(): continue for line in p.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'")) load_env() CONFIG = { "base_url": os.environ.get("SAP_URL", DEFAULT_URL).rstrip("/"), "user": os.environ.get("SAP_USER", ""), "password": os.environ.get("SAP_PASS", ""), "verify_ssl": os.environ.get("SAP_VERIFY_SSL", "0") == "1", } def ssl_context(): ctx = ssl.create_default_context() if not CONFIG["verify_ssl"]: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE return ctx # ------------------------------------------------------------------ XML -> JSON def xml_to_data(el): """asXML 요소를 dict/list/str 로 변환. 행 반복은 list 로: 보통 이지만, 행 타입이 DDIC 구조인 테이블(RPY 의 파라미터 목록 등)은 , , 처럼 구조 이름이 행 태그가 된다. 같은 태그가 반복되거나 부모가 알려진 테이블 필드(LIST_FIELDS)면 list 로 본다. (2026-09-04: 마지막 행만 남던 버그 수정) """ children = list(el) if not children: return el.text or "" tags = {c.tag for c in children} if len(tags) == 1 and (children[0].tag == "item" or len(children) > 1 or el.tag in LIST_FIELDS): return [xml_to_data(c) for c in children] out = {} for c in children: out[c.tag] = xml_to_data(c) return out def coerce_lists(obj): """빈 테이블 은 "" 로 오므로 알려진 테이블 필드는 [] 로 통일.""" if isinstance(obj, dict): for k, v in list(obj.items()): if k in LIST_FIELDS and v == "": obj[k] = [] else: obj[k] = coerce_lists(v) elif isinstance(obj, list): obj = [coerce_lists(x) for x in obj] return obj def normalize_xml(raw: bytes) -> str: """서버 응답 정규화: BOM 제거, 잘못된 utf-16 선언 제거, XML 1.0 비허용 제어문자 제거.""" text = raw.decode("utf-8", errors="replace").lstrip("") return normalize_xml_text(text) def normalize_xml_text(text: str) -> str: """이미 문자열로 디코딩된 응답에 같은 정규화를 적용 (Bruno 가 보내온 본문용).""" text = text.lstrip("") text = re.sub(r"^<\?xml[^>]*\?>", "", text) return "".join(ch for ch in text if ord(ch) >= 0x20 or ch in "\t\n\r") def parse_asxml(text: str) -> dict: """정규화된 asXML 텍스트 -> {parsed, return, error}. sap_call 과 /api/render 가 공유.""" out = {"parsed": None, "return": {"type": "?", "message": ""}, "error": None} try: root = ET.fromstring(text) except ET.ParseError as e: out["error"] = f"XML 파싱 실패: {e}" out["return"] = {"type": "X", "message": out["error"]} return out if root.tag == "error": # ICF 핸들러 에러 봉투 (Method 없음 / ABAP 덤프) err = xml_to_data(root) out["parsed"] = {"error": err} out["error"] = f"{err.get('code')}: {err.get('message')}" out["return"] = {"type": "X", "message": out["error"]} return out data_el = root.find(".//DATA") if data_el is None: # asXML 이 아닌 XML/HTML (로그인 페이지 등) — 성공으로 오인하지 않게 out["error"] = f"SAP 응답이 아님 (루트 <{root.tag}>, DATA 없음)" out["return"] = {"type": "X", "message": out["error"]} return out data = coerce_lists(xml_to_data(data_el)) out["parsed"] = data ret = data.get("RETURN") if isinstance(data, dict) else None if isinstance(ret, dict): out["return"] = {"type": ret.get("TYPE") or "-", "message": ret.get("MESSAGE") or ""} return out # ------------------------------------------------------------------ SAP 호출 def parse_json_response(text: str) -> dict: """JSON 응답(2026-09-14 이후) → {parsed, return, error}. parse_asxml 과 같은 모양.""" out = {"parsed": None, "return": {"type": "?", "message": ""}, "error": None} try: data = json.loads(text) except ValueError as e: out["error"] = f"JSON 파싱 실패: {e}" out["return"] = {"type": "X", "message": out["error"]} return out if isinstance(data, dict) and "RETURN" not in data and "error" in data: # ICF 핸들러 오류 봉투 err = data["error"] if isinstance(data["error"], dict) else {"message": data["error"]} out["parsed"] = {"error": err} out["error"] = f"{err.get('code')}: {err.get('message')}" out["return"] = {"type": "X", "message": out["error"]} return out data = coerce_lists(data) out["parsed"] = data ret = data.get("RETURN") if isinstance(data, dict) else None if isinstance(ret, dict): out["return"] = {"type": ret.get("TYPE") or "-", "message": ret.get("MESSAGE") or ""} return out def parse_response(raw: bytes) -> dict: """본문 첫 글자로 JSON / asXML 을 가른다 (BOM 은 둘 다 뗀다).""" text = raw.decode("utf-8", errors="replace").lstrip("").lstrip() if text[:1] in ("{", "["): return parse_json_response(text) return parse_asxml(normalize_xml_text(text)) def sap_call(method: str, params: dict, timeout: int = 180) -> dict: url = f"{CONFIG['base_url']}/{method}" # 2026-09-14 JSON 규약: 래퍼 없이 입력 필드만. 빈 값은 보내지 않는다 (빠진 키 = 초기값) body_obj = {k: v for k, v in params.items() if v not in ("", None)} body = json.dumps(body_obj, ensure_ascii=False) auth = "Basic " + base64.b64encode(f"{CONFIG['user']}:{CONFIG['password']}".encode()).decode() req = urllib.request.Request( url, data=body.encode("utf-8"), method="POST", headers={"Content-Type": "application/json", "Authorization": auth}, ) curl = (f"curl -sk -u '{CONFIG['user']}:****' -H 'Content-Type: application/json' " f"--data '{body}' '{url}'") result = { "method": method, "request": {"url": url, "body": body_obj, "curl": curl}, "http_status": None, "ms": None, "raw": "", "parsed": None, "return": {"type": "?", "message": ""}, "error": None, } t0 = time.time() try: with urllib.request.urlopen(req, context=ssl_context(), timeout=timeout) as r: raw, status = r.read(), r.status except urllib.error.HTTPError as e: raw, status = e.read(), e.code except Exception as e: # 네트워크/인증 등 result["ms"] = int((time.time() - t0) * 1000) result["error"] = f"{type(e).__name__}: {e}" result["return"] = {"type": "X", "message": result["error"]} return result result["ms"] = int((time.time() - t0) * 1000) result["http_status"] = status raw_text = raw.decode("utf-8", errors="replace") result["raw"] = raw_text result["raw_size"] = len(raw) result.update(parse_response(raw)) return result