359 lines
16 KiB
JavaScript
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, "&").replace(/</g, "<")
|
|
.replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
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);
|