sap_client: ZAA_ICF JSON 규약 대응 (2026-09-14 XML→JSON 전환)

- 요청은 INPUT 래퍼 없이 입력 필드만, 응답은 JSON {"RETURN","RESULT"}. 첫 글자로 asXML 구버전도 자동 판별
- 실측: ZMP_ICF 에서 ZFI01 목록 177건, 프로그램 2본(include 10, T코드 2) 수집 → normalize 정상
- sap/README.md 규약 갱신, tests: JSON/XML 파서·요청 본문 검증

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
byeongwook.choi
2026-09-21 14:41:14 +09:00
co-authored by Claude Fable 5.1
parent 921b8ce229
commit e23140b7af
3 changed files with 87 additions and 4 deletions
+11
View File
@@ -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_...": ...}}` - `POST /sap/bc/ZAA_ICF/{METHOD}`, `Content-Type: application/json`, 바디 `{"INPUT": {"IV_...": ...}}`
- 응답 asXML. XML 선언은 `utf-16` 이지만 실제 UTF-8 → 선언 제거 후 파싱 - 응답 asXML. XML 선언은 `utf-16` 이지만 실제 UTF-8 → 선언 제거 후 파싱
- 소스코드 안 제어문자(0x0C)가 XML 을 깨뜨림 → 파싱 전 제거 - 소스코드 안 제어문자(0x0C)가 XML 을 깨뜨림 → 파싱 전 제거
+38 -4
View File
@@ -5,8 +5,10 @@
- 접속정보: .env 파일 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS / SAP_VERIFY_SSL - 접속정보: .env 파일 또는 환경변수 SAP_URL / SAP_USER / SAP_PASS / SAP_VERIFY_SSL
호출 규약 (실측, README 참고): 호출 규약 (실측, README 참고):
POST {SAP_URL}/{METHOD}, Content-Type: application/json, 바디 {"INPUT": {"IV_...": ...}} POST {SAP_URL}/{METHOD}, Content-Type: application/json
응답 asXML. XML 선언은 utf-16 이지만 실제 UTF-8 → BOM/선언 제거 후 파싱. 소스 안 제어문자(0x0C) 제거. 2026-09-14 부터 JSON: 바디는 래퍼 없이 {"IV_PACKAGE": "ZFI01", "IV_MAX_ROWS": 100} (키 대문자, 숫자는 JSON 숫자,
플래그는 "X"). 응답도 JSON {"RETURN": {...}, "RESULT": [...]} (TOTAL_ROWS 는 숫자, 빈 목록은 []).
구버전(asXML, 바디 {"INPUT": {...}}) 서버도 응답 첫 글자로 판별해 그대로 읽는다.
""" """
import base64 import base64
import json import json
@@ -131,9 +133,41 @@ def parse_asxml(text: str) -> dict:
# ------------------------------------------------------------------ SAP 호출 # ------------------------------------------------------------------ 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: def sap_call(method: str, params: dict, timeout: int = 180) -> dict:
url = f"{CONFIG['base_url']}/{method}" 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) body = json.dumps(body_obj, ensure_ascii=False)
auth = "Basic " + base64.b64encode(f"{CONFIG['user']}:{CONFIG['password']}".encode()).decode() auth = "Basic " + base64.b64encode(f"{CONFIG['user']}:{CONFIG['password']}".encode()).decode()
req = urllib.request.Request( 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") raw_text = raw.decode("utf-8", errors="replace")
result["raw"] = raw_text result["raw"] = raw_text
result["raw_size"] = len(raw) result["raw_size"] = len(raw)
result.update(parse_asxml(normalize_xml(raw))) result.update(parse_response(raw))
return result return result
+38
View File
@@ -111,3 +111,41 @@ def test_dry_run_writes_nothing(tmp_path, sap):
stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True) stats = from_sap.run(["ZFI01"], tmp_path, dry_run=True)
assert stats["listed"] == 2 and stats["fetched"] == 0 assert stats["listed"] == 2 and stats["fetched"] == 0
assert list(tmp_path.iterdir()) == [] 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<?xml version="1.0" encoding="utf-16"?><asx:abap xmlns:asx="x"><asx:values><DATA>'
b'<RETURN><TYPE>S</TYPE><MESSAGE/></RETURN><RESULT/></DATA></asx:values></asx:abap>')
assert xml["error"] is None and xml["return"]["type"] == "S" and xml["parsed"]["RESULT"] == []
bad = parse_response(b"<html>login</html>")
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"] == []