diff --git a/sap/README.md b/sap/README.md index e79d58e..9662af3 100644 --- a/sap/README.md +++ b/sap/README.md @@ -72,6 +72,17 @@ python tools/test_mcp.py [--shrink] # 툴 목록 + 실제 호출 점검 ## 호출 규약 (실측) +**2026-09-14 부터 JSON** (ZCL_ZAI_API, ICF 노드 `/sap/bc/ZMP_ICF`). 구버전 asXML 은 아래 그대로이며, `sap_client` 는 +응답 첫 글자(`{` / `<`)로 둘을 자동 판별한다. + +- 요청: `POST /sap/bc/ZMP_ICF/{METHOD}`, 바디는 **래퍼 없이** `{"IV_PACKAGE":"ZFI01","IV_MAX_ROWS":100}` (키 대문자, 숫자는 JSON 숫자, 플래그는 `"X"`, 일자 `YYYY-MM-DD` 가능) +- 응답: `{"RETURN":{"TYPE","MESSAGE","TOTAL_ROWS"},"RESULT":[...]}` — TOTAL_ROWS 는 숫자, 빈 목록은 `[]`, 소스 줄바꿈은 ` +` +- 필터 없는 목록 호출은 S+안내가 아니라 **E** (`IV_PACKAGE 또는 IV_PATTERN 중 하나는 필수입니다`) +- 실측 2026-09-21: `GET_PROGRAM_LIST ZFI01` 177건 / `GET_PROGRAM_SOURCE` include·T코드 정상 + +### 구버전 (asXML, ~2026-09-13) + - `POST /sap/bc/ZAA_ICF/{METHOD}`, `Content-Type: application/json`, 바디 `{"INPUT": {"IV_...": ...}}` - 응답 asXML. XML 선언은 `utf-16` 이지만 실제 UTF-8 → 선언 제거 후 파싱 - 소스코드 안 제어문자(0x0C)가 XML 을 깨뜨림 → 파싱 전 제거 diff --git a/sap/sap_client.py b/sap/sap_client.py index 11fa369..377e9c1 100644 --- a/sap/sap_client.py +++ b/sap/sap_client.py @@ -5,8 +5,10 @@ - 접속정보: .env 파일 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS / SAP_VERIFY_SSL 호출 규약 (실측, README 참고): - POST {SAP_URL}/{METHOD}, Content-Type: application/json, 바디 {"INPUT": {"IV_...": ...}} - 응답 asXML. XML 선언은 utf-16 이지만 실제 UTF-8 → BOM/선언 제거 후 파싱. 소스 안 제어문자(0x0C) 제거. + 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 @@ -131,9 +133,41 @@ def parse_asxml(text: str) -> dict: # ------------------------------------------------------------------ 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}" - body_obj = {"INPUT": {k: v for k, v in params.items() if v not in ("", None)}} + # 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( @@ -164,5 +198,5 @@ def sap_call(method: str, params: dict, timeout: int = 180) -> dict: raw_text = raw.decode("utf-8", errors="replace") result["raw"] = raw_text result["raw_size"] = len(raw) - result.update(parse_asxml(normalize_xml(raw))) + result.update(parse_response(raw)) return result diff --git a/tests/test_from_sap.py b/tests/test_from_sap.py index 17204d7..9dfaf18 100644 --- a/tests/test_from_sap.py +++ b/tests/test_from_sap.py @@ -111,3 +111,41 @@ def test_dry_run_writes_nothing(tmp_path, sap): stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True) assert stats["listed"] == 2 and stats["fetched"] == 0 assert list(tmp_path.iterdir()) == [] + + +def test_sap_client_parses_json_and_xml_responses(): + from sap.sap_client import parse_response + + js = parse_response('{"RETURN":{"TYPE":"W","MESSAGE":"잘림","TOTAL_ROWS":177},"RESULT":[{"OBJ_NAME":"ZFI0000"}],"INCLUDE_LIST":[]}'.encode("utf-8")) + assert js["error"] is None and js["return"] == {"type": "W", "message": "잘림"} + assert js["parsed"]["RESULT"][0]["OBJ_NAME"] == "ZFI0000" and js["parsed"]["INCLUDE_LIST"] == [] + + xml = parse_response(b'\xef\xbb\xbf' + b'S') + assert xml["error"] is None and xml["return"]["type"] == "S" and xml["parsed"]["RESULT"] == [] + + bad = parse_response(b"login") + assert bad["return"]["type"] == "X" + + +def test_sap_call_sends_fields_without_wrapper(monkeypatch): + import sap.sap_client as client + + captured = {} + + class _Resp: + status = 200 + def __enter__(self): return self + def __exit__(self, *a): return False + def read(self): return b'{"RETURN":{"TYPE":"S","MESSAGE":"","TOTAL_ROWS":0},"RESULT":[]}' + + def fake_urlopen(req, context=None, timeout=None): + captured["body"] = json.loads(req.data.decode("utf-8")) + captured["url"] = req.full_url + return _Resp() + + monkeypatch.setattr(client.urllib.request, "urlopen", fake_urlopen) + res = client.sap_call("GET_PROGRAM_LIST", {"IV_PACKAGE": "ZFI01", "IV_PATTERN": "", "IV_MAX_ROWS": 5}) + assert captured["body"] == {"IV_PACKAGE": "ZFI01", "IV_MAX_ROWS": 5} # 래퍼 없음, 빈 값 제외, 숫자 유지 + assert captured["url"].endswith("/GET_PROGRAM_LIST") + assert res["return"]["type"] == "S" and res["parsed"]["RESULT"] == []