Files
eapim-portal/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js
T
Rinjae 1ac9fa5407
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
"SwaggerUI - 실행 전 응답 UI 노출 방지 로직 추가
- Execute 클릭 여부 확인 pendingExecute 플래그 도입
- 네트워크 에러/응답 처리 시 플래그 초기화 추가"
2026-07-24 09:44:28 +09:00

359 lines
16 KiB
JavaScript

/*!
* DjbSwaggerResponse — Swagger UI 기본 라이브 응답 표(.live-responses-table)를 CSS 로
* 숨기고, 실행(Execute)한 operation 블록 안에 커스텀 응답 패널을 세로 적층으로 렌더한다.
* (요청 스니펫 .request-snippets 은 같은 .responses-wrapper 안에 있으므로 유지)
*
* ┌─────────────────────────────────────────────┐
* │ 응답 헤더 (Table) │
* ├─────────────────────────────────────────────┤
* │ 응답 본문 (JSON Pretty / Raw) │
* ├─────────────────────────────────────────────┤
* │ 응답코드 칩 + Response Time(ms) │
* └─────────────────────────────────────────────┘
*
* 포탈 testbed 는 API 하나에 operation 이 여러 개일 수 있으므로, 전역 단일 그리드가
* 아니라 Execute 를 누른 opblock 안에 그리드를 만든다(클릭 캡처로 대상 특정).
*
* 연결:
* const ui = SwaggerUIBundle({
* responseInterceptor: function (res) { DjbSwaggerResponse.renderResponse(res); return res; }
* });
* window.ui = ui;
* DjbSwaggerResponse.attach('#swagger-ui'); // Execute 클릭 캡처 + 타이머 등록
*/
(function (global) {
"use strict";
var startTs = 0;
var targetOpblock = null;
// Execute 클릭 후에만 true. spec 로딩 등 실행 외 응답이 responseInterceptor 로
// 들어와도 그리드를 만들지 않기 위한 게이트 (실행 전 UI 미노출 보장).
var pendingExecute = false;
function now() {
return (global.performance && performance.now) ? performance.now() : Date.now();
}
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function statusKind(status) {
if (status >= 200 && status < 300) return "ok";
if (status >= 300 && status < 400) return "warn";
if (status >= 400) return "err";
return "neutral";
}
function prettyJson(text) {
try { return JSON.stringify(JSON.parse(text), null, 2); }
catch (e) { return null; }
}
// window.ui 에서 해석된 spec(json) 조회. (snippet-panel 과 동일 방식)
function getSpecJson() {
try {
var ui = global.ui;
if (ui && typeof ui.spec === "function") {
var s = ui.spec();
if (s && typeof s.toJS === "function") {
var o = s.toJS();
return o.json || o;
}
}
} catch (e) { /* noop */ }
return null;
}
// 단일 API 페이지 전제(첫 path/method)로 operation 반환.
function getSingleOperation(spec) {
var paths = (spec && spec.paths) ? spec.paths : {};
var pk = Object.keys(paths)[0];
if (!pk) return null;
var ops = paths[pk];
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
if (!mk) return null;
return { path: pk, method: mk, operation: ops[mk] };
}
// opblock 안 파라미터 입력값(header/query) 읽기.
function readParamInput(opblock, name, where) {
var scope = opblock || document;
var row = scope.querySelector('tr[data-param-name="' + name + '"][data-param-in="' + where + '"]');
if (row) {
var input = row.querySelector('input[type="text"], input:not([type]), textarea');
if (input && input.value) return input.value;
}
return undefined;
}
// Execute 직전 상태에서 검증용 요청 객체를 조립(헤더/쿼리/본문).
function buildValidationRequest(opblock, opInfo) {
var op = opInfo.operation;
var headers = {};
var query = [];
(op.parameters || []).forEach(function (p) {
if (p.in === "header") {
var hv = readParamInput(opblock, p.name, "header");
if (hv !== undefined) headers[p.name] = hv;
} else if (p.in === "query") {
var qv = readParamInput(opblock, p.name, "query");
if (qv !== undefined) query.push(encodeURIComponent(p.name) + "=" + encodeURIComponent(qv));
}
});
var body = "";
if (op.requestBody) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
var ta = (opblock || document).querySelector("textarea.body-param__text");
if (ta) body = ta.value;
}
var url = "http://_local" + opInfo.path + (query.length ? "?" + query.join("&") : "");
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
}
function renderEmpty() {
return ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">요청 중…</div></div>'
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
+ '<div class="empty">요청 중…</div></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip neutral">대기</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
function renderHeadersTable(headers) {
var keys = Object.keys(headers || {});
if (!keys.length) return '<div class="empty">헤더가 없습니다.</div>';
var rows = keys.map(function (k) {
return '<tr><td class="hdr-name">' + escapeHtml(k) + '</td>'
+ '<td class="hdr-val">' + escapeHtml(headers[k]) + '</td></tr>';
}).join("");
return '<table class="hdr-table"><thead><tr><th>이름</th><th>값</th></tr></thead>'
+ '<tbody>' + rows + '</tbody></table>';
}
function bodyTabs(rawText) {
var pretty = prettyJson(rawText);
var hasJson = pretty != null;
var jsonTab = hasJson ? '<button type="button" class="body-tab active" data-tab="json">JSON (Pretty)</button>' : "";
var rawTab = '<button type="button" class="body-tab' + (hasJson ? "" : " active") + '" data-tab="raw">Raw</button>';
var jsonPanel = hasJson ? '<pre class="body-panel mono active" data-tab="json">' + escapeHtml(pretty) + '</pre>' : "";
var rawPanel = '<pre class="body-panel mono' + (hasJson ? "" : " active") + '" data-tab="raw">' + escapeHtml(rawText || "") + '</pre>';
return '<div class="body-tabs" role="tablist">' + jsonTab + rawTab + '</div>'
+ '<div class="body-panels">' + jsonPanel + rawPanel + '</div>';
}
function attachTabHandlers(root) {
if (root.__djbTabBound) return;
root.__djbTabBound = true;
root.addEventListener("click", function (e) {
var btn = e.target.closest && e.target.closest(".body-tab");
if (!btn) return;
var panels = root.querySelectorAll(".body-panel");
var tabs = root.querySelectorAll(".body-tab");
tabs.forEach(function (t) { t.classList.toggle("active", t === btn); });
var which = btn.getAttribute("data-tab");
panels.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-tab") === which); });
});
}
// 대상 opblock(없으면 열려있는/첫 opblock) 안 .opblock-body 에 그리드 1개 확보.
// 항상 opblock-body 최하단으로 이동시킨다: Execute 시점엔 responses-wrapper(요청
// 스니펫)가 아직 없어 그리드가 그 위에 붙지만, 응답 후 재호출 시 최하단으로 옮겨
// "Execute → 요청 스니펫 → 응답 그리드" 순서를 보장한다.
function gridFor(opblock) {
var host = opblock
|| document.querySelector("#swagger-ui .opblock.is-open")
|| document.querySelector("#swagger-ui .opblock");
if (!host) return null;
var body = host.querySelector(".opblock-body");
if (!body) return null; // 접힘 상태에는 마운트하지 않음 (bare opblock 오배치 방지)
var grid = body.querySelector(".djb-response-grid");
if (!grid) {
grid = document.createElement("div");
grid.className = "djb-response-grid djb-empty";
grid.innerHTML = renderEmpty();
attachTabHandlers(grid);
}
body.appendChild(grid); // 기존 노드면 최하단으로 이동
return grid;
}
// 검증 실패 렌더: 네트워크 요청이 발생하지 않은 상태. djb-error 스타일 그리드.
function renderValidationErrors(errors) {
var grid = gridFor(targetOpblock);
if (!grid) return;
var items = (errors || []).map(function (er) {
return '<li class="verr-item">'
+ '<span class="verr-path">' + escapeHtml(er.path) + '</span> '
+ '<span class="verr-msg">' + escapeHtml(er.message) + '</span>'
+ (er.value !== undefined ? ' <code class="verr-val mono">' + escapeHtml(JSON.stringify(er.value)) + '</code>' : "")
+ '</li>';
}).join("");
grid.classList.remove("djb-empty");
grid.classList.add("djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">검증 실패 — 요청이 전송되지 않았습니다.</div></div>'
+ '<div class="cell-body"><div class="cell-title">검증 실패 (' + (errors || []).length + '건)</div>'
+ '<ul class="verr-list">' + items + '</ul></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip err">검증실패</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
function renderNetworkError(msg) {
pendingExecute = false;
var grid = gridFor(targetOpblock);
if (!grid) return;
grid.classList.remove("djb-empty");
grid.classList.add("djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">응답을 받지 못했습니다.</div></div>'
+ '<div class="cell-body"><div class="cell-title">네트워크 오류</div>'
+ '<pre class="body-panel mono active" data-tab="raw">요청 실패: ' + escapeHtml(msg) + '</pre></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip err">네트워크 오류</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
function onExecuteClick(e) {
var t = e.target;
if (!t || !t.closest) return;
var btn = t.closest(".btn.execute");
if (!btn) return;
targetOpblock = btn.closest(".opblock");
// ── 요청 파라미터/본문 검증 (검증기 로드된 경우) ──
if (global.DjbSwaggerValidator) {
try {
var spec = getSpecJson();
var opInfo = spec && getSingleOperation(spec);
if (opInfo) {
var req = buildValidationRequest(targetOpblock, opInfo);
var result = global.DjbSwaggerValidator.validateOperation(opInfo.operation, req, spec);
if (!result.ok) {
e.preventDefault();
e.stopImmediatePropagation();
renderValidationErrors(result.errors);
return;
}
}
} catch (err) { console.error("djb validation error:", err); }
}
startTs = now();
pendingExecute = true;
var grid = gridFor(targetOpblock);
if (grid) {
grid.classList.add("djb-empty");
grid.classList.remove("djb-error");
grid.innerHTML = renderEmpty();
}
}
/**
* @param {Object} res Swagger UI responseInterceptor 결과
* { status, statusText, headers, text, data, ... }
*/
function renderResponse(res) {
if (!res) return;
// Execute 를 누른 적 없으면 무시 — spec 로딩(/…/swagger.json) 응답도
// responseInterceptor 로 들어오는데, 이때 렌더하면 실행 전 응답 본문에
// OpenAPI spec 이 그려진다. url 검사는 버전에 따라 res.url 이 비어 무력화될
// 수 있어 실행 게이트를 1차 방어로 둔다.
if (!pendingExecute) return;
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
pendingExecute = false;
var ms = Math.max(0, Math.round(now() - startTs));
var status = res.status || 0;
var statusText = res.statusText || "";
var headers = res.headers || {};
var rawText = (typeof res.text === "string") ? res.text
: (typeof res.data === "string") ? res.data
: (res.data != null ? JSON.stringify(res.data) : "");
var op = targetOpblock;
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
setTimeout(function () {
var grid = gridFor(op);
if (!grid) return;
var kind = statusKind(status);
grid.classList.remove("djb-empty", "djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ renderHeadersTable(headers) + '</div>'
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
+ bodyTabs(rawText) + '</div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip ' + kind + '">응답코드: ' + escapeHtml(status)
+ (statusText ? " " + escapeHtml(statusText) : "") + '</span>'
+ '<span class="time">Response Time: ' + ms + ' ms</span>'
+ '</div>';
attachTabHandlers(grid);
}, 0);
}
// fetch 실패(네트워크/CORS/타임아웃)는 responseInterceptor 로 전달되지 않고 store 의
// spec.responses[path][method] 에 { error:true, err } 로만 저장된다. store 를 구독해
// 사용자에게 네트워크 오류 그리드로 보여준다. (PoC bootstrap 의 getStore().subscribe 이식)
function watchNetworkErrors(ui) {
if (!ui || typeof ui.getStore !== "function" || ui.__djbNetWatch) return;
ui.__djbNetWatch = true;
var rendered = {};
ui.getStore().subscribe(function () {
try {
var respMap = ui.getState().getIn(["spec", "responses"]);
if (!respMap || typeof respMap.forEach !== "function") return;
respMap.forEach(function (methods, path) {
if (!methods || typeof methods.forEach !== "function") return;
methods.forEach(function (resp, method) {
var key = path + " " + method;
var hasError = resp && (resp.get ? resp.get("error") : resp.error);
if (hasError) {
if (rendered[key]) return;
rendered[key] = true;
var err = resp.get ? resp.get("err") : resp.err;
var msg = (err && (err.message || (err.get && err.get("message"))))
|| (err ? String(err) : "응답을 받지 못했습니다.");
renderNetworkError(String(msg));
} else {
rendered[key] = false;
}
});
});
} catch (e) { /* ignore */ }
});
}
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
function attach(rootSel) {
var root = document.querySelector(rootSel || "#swagger-ui");
if (!root || root.__djbRespBound) return;
root.__djbRespBound = true;
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보 + 검증 차단
root.addEventListener("click", onExecuteClick, true);
// window.ui 준비 시 네트워크 오류 감시 등록
watchNetworkErrors(global.ui);
}
global.DjbSwaggerResponse = {
attach: attach,
renderResponse: renderResponse,
renderValidationErrors: renderValidationErrors,
renderNetworkError: renderNetworkError,
watchNetworkErrors: watchNetworkErrors
};
})(window);