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:
co-authored by
Claude Fable 5.1
parent
921b8ce229
commit
e23140b7af
@@ -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 을 깨뜨림 → 파싱 전 제거
|
||||
|
||||
+38
-4
@@ -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
|
||||
|
||||
@@ -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<?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"] == []
|
||||
|
||||
Reference in New Issue
Block a user