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
+38 -4
View File
@@ -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