# Conflicts:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

#	src/main/resources/static/css/main.css.map
This commit is contained in:
Rinjae
2026-07-13 13:29:54 +09:00
parent 3e403788e5
commit 778d579ada
56 changed files with 3330 additions and 9285 deletions
+111
View File
@@ -9443,6 +9443,117 @@ button.djb-comment-submit:disabled {
display: flex;
}
.testbed-app-panel {
margin: 8px 0 24px;
padding: 24px;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.testbed-app-panel .testbed-app-panel__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
margin-bottom: 16px;
}
.testbed-app-panel .testbed-app-panel__title {
position: relative;
padding-left: 16px;
font-size: 16px;
font-weight: 700;
color: #1A1A2E;
letter-spacing: -0.01em;
}
.testbed-app-panel .testbed-app-panel__title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 15px;
background: #0049b4;
border-radius: 2px;
}
.testbed-app-panel .testbed-app-panel__desc {
font-size: 14px;
color: #64748B;
}
.testbed-app-panel .testbed-app-field {
position: relative;
max-width: 380px;
}
.testbed-app-panel .testbed-app-field::after {
content: "";
position: absolute;
right: 16px;
top: 50%;
width: 9px;
height: 9px;
margin-top: -6px;
border-right: 2px solid #64748B;
border-bottom: 2px solid #64748B;
transform: rotate(45deg);
pointer-events: none;
}
.testbed-app-panel #apps {
width: 100%;
height: 46px;
padding: 0 42px 0 16px;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
color: #1A1A2E;
background: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 8px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.15s ease;
}
.testbed-app-panel #apps:hover:not(:disabled) {
border-color: #94A3B8;
background: #FFFFFF;
}
.testbed-app-panel #apps:focus {
outline: none;
background: #FFFFFF;
border-color: #0049b4;
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.14);
}
.testbed-app-panel #apps:disabled {
color: #94A3B8;
background: #F8FAFC;
cursor: not-allowed;
}
.testbed-app-panel .testbed-app-notice {
margin: 16px 0 0;
padding: 11px 16px;
font-size: 14px;
line-height: 1.6;
color: #64748B;
background: #EFF6FF;
border: 1px solid rgba(0, 73, 180, 0.18);
border-left: 3px solid #0049b4;
border-radius: 6px;
}
.testbed-app-panel .testbed-app-notice::before {
content: "ⓘ ";
color: #0049b4;
font-weight: 700;
}
@media (max-width: 768px) {
.testbed-app-panel {
padding: 16px;
}
.testbed-app-panel .testbed-app-field {
max-width: 100%;
}
}
.api-overview-card {
background: transparent;
border-radius: 0;
@@ -0,0 +1,161 @@
/*!
* djb-swagger-i18n.js
* Swagger UI 영문 라벨을 한글로 치환하는 i18n 패치 (방법 B - DOM patch)
* 배포 경로 권장: static/plugins/swaggerUI/djb-swagger-i18n.js
* index.html 의 swagger-initializer.js 직전 또는 직후에 <script> 로 로드
*
* ?lang=en 쿼리스트링이 있으면 패치 비활성화 (원문 보기)
* 매핑 카탈로그는 01-i18n-mapping/i18n-strings.csv 와 동기 유지
*/
(function (window) {
'use strict';
// ── 비활성화 옵션 ──
if (/[?&]lang=en\b/.test(window.location.search)) return;
// ── 매핑 (key 는 디버깅용, 실제 비교는 EN 문자열 일치) ──
var I18N_MAP = {
// P0 — OAuth2 모달 본문
'auth.modal.title': { en: 'Available authorizations', ko: '사용 가능한 인증' },
'auth.scopes.description': {
en: 'Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.',
ko: '스코프는 최종 사용자를 대신하여 애플리케이션이 데이터에 접근할 수 있는 권한 수준을 부여합니다. 각 API는 하나 이상의 스코프를 선언할 수 있습니다.'
},
'auth.scopes.swagger_grant': {
en: 'API requires the following scopes. Select which ones you want to grant to Swagger UI.',
ko: '이 API 는 아래 스코프가 필요합니다. Swagger UI 에 부여할 스코프를 선택하세요.'
},
'auth.flow.clientCredentials': { en: 'OAuth2 client credentials flow', ko: 'OAuth2 클라이언트 자격증명 흐름' },
'auth.flow.implicit': { en: 'OAuth2 implicit flow', ko: 'OAuth2 암묵적 흐름' },
'auth.flow.password': { en: 'OAuth2 password flow', ko: 'OAuth2 비밀번호 흐름' },
'auth.flow.authorizationCode': { en: 'OAuth2 authorization code flow', ko: 'OAuth2 인가 코드 흐름' },
'auth.field.tokenUrl': { en: 'Token URL:', ko: '토큰 URL:' },
'auth.field.flow': { en: 'Flow:', ko: '흐름:' },
'auth.field.clientId': { en: 'client_id:', ko: '클라이언트 ID (client_id):' },
'auth.field.clientSecret': { en: 'client_secret:',ko: '클라이언트 시크릿 (client_secret):' },
'auth.field.scopes': { en: 'Scopes:', ko: '스코프:' },
'auth.action.selectAll': { en: 'select all', ko: '모두 선택' },
'auth.action.selectNone': { en: 'select none', ko: '선택 해제' },
'auth.action.authorize': { en: 'Authorize', ko: '인증' },
'auth.action.close': { en: 'Close', ko: '닫기' },
'auth.action.logout': { en: 'Logout', ko: '로그아웃' },
'auth.modal.authorized': { en: 'authorized', ko: '인증됨' },
// P1 — 기타 인증 흐름
'auth.scheme.apiKey': { en: 'API key', ko: 'API 키' },
'auth.field.name': { en: 'name:', ko: '이름:' },
'auth.field.in': { en: 'in:', ko: '위치:' },
'auth.field.value': { en: 'Value:', ko: '값:' },
'basic.field.username': { en: 'Username:',ko: '사용자 ID:' },
'basic.field.password': { en: 'Password:',ko: '비밀번호:' },
// P2 — 오퍼레이션 / 파라미터 / 응답 UI 라벨 (PoC ko-i18n.js 카탈로그와 동기)
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
'op.cancel': { en: 'Cancel', ko: '취소' },
'op.execute': { en: 'Execute', ko: '실행' },
'op.clear': { en: 'Clear', ko: '지우기' },
'op.reset': { en: 'Reset', ko: '초기화' },
'op.parameters': { en: 'Parameters', ko: '파라미터' },
'op.parameter': { en: 'Parameter', ko: '파라미터' },
'op.name': { en: 'Name', ko: '이름' },
'op.description': { en: 'Description', ko: '설명' },
'op.value': { en: 'Value', ko: '값' },
'op.type': { en: 'Type', ko: '타입' },
'op.requestBody': { en: 'Request body', ko: '요청 본문' },
'op.requestUrl': { en: 'Request URL', ko: '요청 URL' },
'op.responses': { en: 'Responses', ko: '응답' },
'op.response': { en: 'Response', ko: '응답' },
'op.responseBody': { en: 'Response body', ko: '응답 본문' },
'op.responseHeaders': { en: 'Response headers', ko: '응답 헤더' },
'op.responseCType': { en: 'Response content type', ko: '응답 컨텐츠 타입' },
'op.code': { en: 'Code', ko: '코드' },
'op.details': { en: 'Details', ko: '상세' },
'op.schema': { en: 'Schema', ko: '스키마' },
'op.schemas': { en: 'Schemas', ko: '스키마' },
'op.exampleValue': { en: 'Example Value', ko: '예시 값' },
'op.noParameters': { en: 'No parameters', ko: '파라미터 없음' },
'op.required': { en: 'required', ko: '필수' },
'op.servers': { en: 'Servers', ko: '서버' },
'op.serverResponse': { en: 'Server response', ko: '서버 응답' },
'op.loading': { en: 'Loading...', ko: '불러오는 중...' },
'op.curlUpper': { en: 'Curl', ko: 'cURL' },
'op.curlLower': { en: 'curl', ko: 'cURL' },
'op.editValue': { en: 'Edit Value', ko: '값 편집' },
'op.model': { en: 'Model', ko: '모델' },
'op.snippets': { en: 'Snippets', ko: '스니펫' },
'op.mediaType': { en: 'Media type', ko: '미디어 타입' },
'op.controlsAccept': { en: 'Controls Accept header.', ko: 'Accept 헤더를 제어합니다.' },
'op.sendEmptyValue': { en: 'Send empty value', ko: '빈 값 전송' },
'op.hide': { en: 'Hide', ko: '숨기기' },
'op.show': { en: 'Show', ko: '표시' }
};
// 빠른 lookup 을 위해 EN → KO 인덱스 사전 생성
var EN_TO_KO = Object.create(null);
Object.keys(I18N_MAP).forEach(function (k) {
var e = I18N_MAP[k];
EN_TO_KO[e.en] = e.ko;
});
// ── 치환 대상 노드 식별 + 치환 ──
function translateNode(node) {
if (!node) return;
if (node.nodeType === Node.TEXT_NODE) {
var raw = node.nodeValue;
if (!raw) return;
var trimmed = raw.trim();
if (!trimmed) return;
// 1) 완전 일치 (가장 안전)
if (EN_TO_KO[trimmed]) {
node.nodeValue = raw.replace(trimmed, EN_TO_KO[trimmed]);
return;
}
// 2) 라벨 colon 패턴: "Token URL:" 같이 끝이 ":" 인 짧은 라벨
// (불필요 — 1번 완전 일치로 대부분 커버)
return;
}
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
// input placeholder / button title 도 일부 케이스에 대비
if (node.tagName === 'INPUT' && node.placeholder && EN_TO_KO[node.placeholder.trim()]) {
node.placeholder = EN_TO_KO[node.placeholder.trim()];
}
var i, child = node.childNodes, len = child.length;
for (i = 0; i < len; i++) translateNode(child[i]);
}
function translateAll() {
var root = document.getElementById('swagger-ui') || document.body;
translateNode(root);
}
// ── MutationObserver — SwaggerUI 가 동적으로 DOM 을 다시 그릴 때마다 재적용 ──
var observer = new MutationObserver(function (mutations) {
for (var i = 0; i < mutations.length; i++) {
var m = mutations[i];
if (m.addedNodes && m.addedNodes.length) {
for (var j = 0; j < m.addedNodes.length; j++) translateNode(m.addedNodes[j]);
}
if (m.type === 'characterData') {
translateNode(m.target);
}
}
});
function start() {
translateAll();
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})(window);
@@ -0,0 +1,350 @@
/*!
* 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;
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) {
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();
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;
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
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);
@@ -0,0 +1,265 @@
/*!
* DjbSwaggerSnippetPanel — Execute 없이 페이지 진입 즉시 요청 스니펫을 보여주는 상시 패널.
* (PoC rinjae-works/swagger-poc/js/snippet-panel.js 를 포탈용으로 이식)
*
* Swagger UI 5 의 내장 .request-snippets 는 Execute 이후에만, 그리고 응답영역 안에
* 렌더되므로 CSS 로 숨기고, 동일한 window.SnippetGenerators 로 직접 패널을 만들어
* operation 의 Execute 버튼(.btn-group) 바로 아래에 마운트한다. Body/헤더 변경 시 자동 갱신.
*
* 전제: 이 페이지는 항상 1개 API(단일 operation) 노출 — spec 의 첫 path/method 사용.
* spec 은 window.ui 에서 지연 조회한다.
*
* 연결: SwaggerUIBundle 의 onComplete 에서 DjbSwaggerSnippetPanel.mount() 호출.
*/
(function (global) {
"use strict";
var TABS = [
{ key: "curl_bash", title: "cURL (Bash)", gen: "curlBash" },
{ key: "curl_powershell", title: "cURL (PowerShell)", gen: "curlPwsh" },
{ key: "curl_cmd", title: "cURL (CMD)", gen: "curlCmd" },
{ key: "csharp", title: "C#", gen: "csharp" },
{ key: "ecma5", title: "ECMA5 (JavaScript)", gen: "ecma5" },
{ key: "java", title: "Java", gen: "javaOkhttp" },
{ key: "python", title: "Python", gen: "python" }
];
var activeKey = "curl_bash";
var rootSel = "#swagger-ui";
var observer = null;
function escapeHtml(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;")
.replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// window.ui 에서 해석된 spec(json) 조회.
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;
}
// Path 1 + Method 1 전제 하에 단일 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] };
}
function readHeaderInput(name) {
var row = document.querySelector('tr[data-param-name="' + name + '"][data-param-in="header"]');
if (row) {
var input = row.querySelector('input[type="text"], input:not([type])');
if (input && input.value) return input.value;
}
return null;
}
// Authorize 모달 사용 후에만 표시되는 인증 헤더 추출.
function readAuthHeaders() {
var headers = {};
try {
var ui = global.ui;
if (!ui || !ui.authSelectors) return headers;
var auths = ui.authSelectors.authorized();
if (!auths) return headers;
var js = auths.toJS();
Object.keys(js).forEach(function (key) {
var a = js[key];
if (!a || !a.schema) return;
var scheme = a.schema;
if (scheme.type === "oauth2") {
var tok = a.token && a.token.access_token;
if (tok) headers["Authorization"] = "Bearer " + tok;
} else if (scheme.type === "apiKey" && scheme.in === "header") {
if (a.value) headers[scheme.name] = a.value;
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
if (a.value) headers["Authorization"] = "Bearer " + a.value;
}
});
} catch (e) { /* noop */ }
return headers;
}
function buildRequest() {
var spec = getSpecJson();
var opInfo = spec && getSingleOperation(spec);
if (!opInfo) return null;
var op = opInfo.operation;
var server = (spec.servers && spec.servers[0] && spec.servers[0].url) || "";
var url = server + opInfo.path;
var headers = {};
(op.parameters || []).forEach(function (p) {
if (p.in !== "header") return;
var val = readHeaderInput(p.name)
|| (p.example !== undefined ? String(p.example) : null)
|| (p.schema && p.schema.example !== undefined ? String(p.schema.example) : null);
if (val) headers[p.name] = val;
});
var body = "";
if (op.requestBody && op.requestBody.content) {
var cts = Object.keys(op.requestBody.content);
var ct = cts[0] || "application/json";
headers["Content-Type"] = ct;
var ta = document.querySelector(rootSel + " textarea.body-param__text");
if (ta && ta.value) {
body = ta.value;
} else if (op.requestBody.content[ct] && op.requestBody.content[ct].example !== undefined) {
try { body = JSON.stringify(op.requestBody.content[ct].example, null, 2); } catch (e) { body = ""; }
}
}
headers["accept"] = headers["accept"] || "application/json";
var auth = readAuthHeaders();
Object.keys(auth).forEach(function (k) { headers[k] = auth[k]; });
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
}
function panelEl() { return document.querySelector("#djb-snippet-panel"); }
function renderInto(el) {
if (!el) return;
var gens = global.SnippetGenerators;
var code;
if (!gens) {
code = "// 스니펫 생성기 로드 대기…";
} else {
try {
var req = buildRequest();
if (!req) { code = "// 스펙 로딩 중…"; }
else {
var active = TABS.filter(function (t) { return t.key === activeKey; })[0] || TABS[0];
code = gens[active.gen](req);
}
} catch (e) { code = "// 스니펫 생성 오류: " + e.message; }
}
var tabsHtml = TABS.map(function (t) {
return '<button type="button" class="djb-sn-tab ' + (t.key === activeKey ? "active" : "") + '" data-key="' + t.key + '">' + t.title + '</button>';
}).join("");
el.innerHTML =
'<div class="djb-sn-header">'
+ '<h4>요청 스니펫</h4>'
+ '<span class="djb-sn-hint">(실행 없이 즉시 미리보기 — Body / 헤더 변경 시 자동 갱신)</span>'
+ '<button type="button" class="djb-sn-copy" data-action="copy">복사</button>'
+ '</div>'
+ '<div class="djb-sn-tabs" role="tablist">' + tabsHtml + '</div>'
+ '<pre class="djb-sn-code mono">' + escapeHtml(code) + '</pre>';
}
// 옵저버 재진입(무한 루프) 방지: 우리 DOM 변경 중에는 관찰 일시 중지.
function withObserverPaused(fn) {
if (observer) observer.disconnect();
try { fn(); }
finally {
if (observer) {
var root = document.querySelector(rootSel);
if (root) observer.observe(root, { childList: true, subtree: true });
}
}
}
// Execute 영역 바로 아래 = Swagger UI 폼 하단에 패널 확보.
// - 앵커는 opblock-body '직속' 자식만 본다: 실행 전에는 .execute-wrapper, 실행 후에는
// Swagger UI 5 가 이를 .btn-group(실행+초기화 버튼군)으로 '교체'하므로 둘 다 허용한다.
// (execute-wrapper 만 앵커로 쓰면 실행 후 앵커 소실 → 패널이 제거되고 재생성 안 됨.)
// - 중첩 .btn-group(파라미터 섹션 헤더의 try-out '취소' 버튼군)은 직속 자식이 아니라
// 건너뛴다: 그걸 앵커로 잡으면 flex 헤더 안에 삽입돼 "파라미터" 라벨이 찌부된다.
// - operation 이 접혀 opblock-body/Execute 영역이 없으면: 잘못 남은 패널을 제거하고 중단.
// 다음 펼침 때 재생성.
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
function ensureMounted() {
var op = document.querySelector(rootSel + " .opblock.is-open")
|| document.querySelector(rootSel + " .opblock");
var body = op && op.querySelector(".opblock-body");
var anchor = null;
if (body) {
for (var i = 0; i < body.children.length; i++) {
var kid = body.children[i];
if (kid.classList.contains("execute-wrapper") || kid.classList.contains("btn-group")) {
anchor = kid;
break;
}
}
}
var existing = panelEl();
if (!body || !anchor) {
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
return;
}
if (existing) {
if (existing.parentNode !== anchor.parentNode || existing.previousElementSibling !== anchor) {
anchor.parentNode.insertBefore(existing, anchor.nextSibling);
}
return;
}
var panel = document.createElement("div");
panel.id = "djb-snippet-panel";
panel.className = "djb-snippet-panel";
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
renderInto(panel);
}
function onClick(e) {
var tab = e.target.closest && e.target.closest("#djb-snippet-panel .djb-sn-tab");
if (tab) {
activeKey = tab.getAttribute("data-key");
withObserverPaused(function () { renderInto(panelEl()); });
return;
}
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
if (copy) {
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
if (code && global.navigator && navigator.clipboard) {
navigator.clipboard.writeText(code.innerText).then(function () {
copy.textContent = "복사됨";
setTimeout(function () { copy.textContent = "복사"; }, 1200);
});
}
}
}
function onInput(e) {
var t = e.target;
if (!t || !t.matches) return;
if (t.matches("textarea.body-param__text") || t.matches("tr[data-param-name] input")) {
withObserverPaused(function () { renderInto(panelEl()); });
}
}
function mount(opts) {
rootSel = (opts && opts.rootSel) || "#swagger-ui";
var root = document.querySelector(rootSel);
if (!root) return;
if (!root.__djbSnBound) {
root.__djbSnBound = true;
root.addEventListener("click", onClick);
root.addEventListener("input", onInput);
observer = new MutationObserver(function () {
withObserverPaused(function () { ensureMounted(); });
});
observer.observe(root, { childList: true, subtree: true });
}
withObserverPaused(function () { ensureMounted(); });
}
global.DjbSwaggerSnippetPanel = { mount: mount, render: function () { renderInto(panelEl()); } };
})(window);
@@ -0,0 +1,223 @@
/*!
* DjbSwaggerSnippets — Swagger UI 의 SnippetGeneratorPlugin 에 등록되는 7개 언어
* 클라이언트 코드 생성기. 서버측 /api/sample_code/{language} 호출을 대체한다.
*
* cURL (Bash) / cURL (PowerShell) / cURL (CMD) / C# / ECMA5 (JS) / Java / Python
*
* 입력: Swagger UI requestSnippetGenerator 가 넘기는 Immutable request.
* .toJS() 로 plain object 변환 후 url/method/headers/body 사용.
* (showMutatedRequest:false 이므로 프록시 URL 이 아닌 실제 대상 요청이 넘어온다)
* 출력: string (코드 본문)
*
* 전역: window.SnippetGeneratorPlugin (Swagger plugins 배열에 등록),
* window.SnippetGenerators (개별 생성 함수 접근용)
*/
(function (global) {
"use strict";
function unwrap(req) {
if (!req) return {};
if (typeof req.toJS === "function") return req.toJS();
return req;
}
function getCt(headers) {
for (const k of Object.keys(headers || {})) {
if (k.toLowerCase() === "content-type") return headers[k];
}
return null;
}
function bodyAsString(body) {
if (body === undefined || body === null) return "";
if (typeof body === "string") return body;
try { return JSON.stringify(body); } catch (e) { return String(body); }
}
function escapeSingleQuote(s) { return String(s).replace(/'/g, "'\\''"); }
function escapeDoubleQuote(s) { return String(s).replace(/"/g, '\\"'); }
// ─── cURL (Bash) ────────────────────────────────────────────────────────────
function curlBash(reqRaw) {
const r = unwrap(reqRaw);
const lines = [`curl --location --request ${r.method || "GET"} '${escapeSingleQuote(r.url)}'`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
lines.push(` --header '${escapeSingleQuote(k)}: ${escapeSingleQuote(headers[k])}'`);
}
const body = bodyAsString(r.body);
if (body) {
lines.push(` --data-raw '${escapeSingleQuote(body)}'`);
}
return lines.join(" \\\n");
}
// ─── cURL (PowerShell) ─────────────────────────────────────────────────────
function curlPwsh(reqRaw) {
const r = unwrap(reqRaw);
// 백틱(`) 줄 연속자
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
}
const body = bodyAsString(r.body);
if (body) {
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
}
return segments.join(" `\n");
}
// ─── cURL (Windows CMD) ────────────────────────────────────────────────────
function curlCmd(reqRaw) {
const r = unwrap(reqRaw);
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
const headers = r.headers || {};
for (const k of Object.keys(headers)) {
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
}
const body = bodyAsString(r.body);
if (body) {
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
}
return segments.join(" ^\n");
}
// ─── C# (RestSharp) ─────────────────────────────────────────────────────────
function csharp(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const lines = [
`using RestSharp;`,
``,
`var client = new RestClient("${escapeDoubleQuote(r.url)}");`,
`var request = new RestRequest(Method.${method});`,
];
for (const k of Object.keys(headers)) {
lines.push(`request.AddHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
}
if (body) {
const ct = getCt(headers) || "application/json";
lines.push(`request.AddParameter("${escapeDoubleQuote(ct)}", @"${body.replace(/"/g, '""')}", ParameterType.RequestBody);`);
}
lines.push(`IRestResponse response = client.Execute(request);`);
lines.push(`Console.WriteLine(response.Content);`);
return lines.join("\n");
}
// ─── ECMA5 / JavaScript (XHR) ──────────────────────────────────────────────
function ecma5(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const lines = [
`var xhr = new XMLHttpRequest();`,
`xhr.withCredentials = false;`,
``,
`xhr.addEventListener("readystatechange", function () {`,
` if (this.readyState === 4) {`,
` console.log(this.responseText);`,
` }`,
`});`,
``,
`xhr.open("${method}", "${escapeDoubleQuote(r.url)}");`,
];
for (const k of Object.keys(headers)) {
lines.push(`xhr.setRequestHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
}
if (body) {
// ES5 라 template literal 사용 X — JSON.stringify 로 안전한 문자열 리터럴 생성
lines.push(`xhr.send(${JSON.stringify(body)});`);
} else {
lines.push(`xhr.send(null);`);
}
return lines.join("\n");
}
// ─── Java (OkHttp) ─────────────────────────────────────────────────────────
function javaOkhttp(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toUpperCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const ct = getCt(headers) || "application/json";
const lines = [
`OkHttpClient client = new OkHttpClient().newBuilder().build();`,
`MediaType mediaType = MediaType.parse("${escapeDoubleQuote(ct)}");`,
];
if (body) {
lines.push(`RequestBody body = RequestBody.create(mediaType, "${escapeDoubleQuote(body)}");`);
}
lines.push(`Request request = new Request.Builder()`);
lines.push(` .url("${escapeDoubleQuote(r.url)}")`);
if (body) {
lines.push(` .method("${method}", body)`);
} else {
lines.push(` .method("${method}", null)`);
}
for (const k of Object.keys(headers)) {
lines.push(` .addHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}")`);
}
lines.push(` .build();`);
lines.push(`Response response = client.newCall(request).execute();`);
return lines.join("\n");
}
// ─── Python (requests) ─────────────────────────────────────────────────────
function python(reqRaw) {
const r = unwrap(reqRaw);
const method = (r.method || "GET").toLowerCase();
const headers = r.headers || {};
const body = bodyAsString(r.body);
const headerEntries = Object.keys(headers).map(
k => ` "${escapeDoubleQuote(k)}": "${escapeDoubleQuote(headers[k])}"`
);
const lines = [
`import requests`,
``,
`url = "${escapeDoubleQuote(r.url)}"`,
``,
];
if (body) {
lines.push(`payload = ${JSON.stringify(body)}`);
}
lines.push(`headers = {`);
if (headerEntries.length) lines.push(headerEntries.join(",\n"));
lines.push(`}`);
lines.push(``);
if (body) {
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers, data=payload)`);
} else {
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers)`);
}
lines.push(`print(response.text)`);
return lines.join("\n");
}
// ─── Swagger UI 플러그인 등록 페이로드 ──────────────────────────────────────
const SnippetGeneratorPlugin = function () {
return {
fn: {
// curl_bash / curl_powershell / curl_cmd 는 Swagger UI 5 내장 기본 키와 동일하게
// 맞춰 내장 생성기를 덮어쓴다. (curl_pwsh 등 다른 키를 쓰면 내장 PowerShell 탭이
// 중복 표시됨)
requestSnippetGenerator_curl_bash: curlBash,
requestSnippetGenerator_curl_powershell: curlPwsh,
requestSnippetGenerator_curl_cmd: curlCmd,
requestSnippetGenerator_csharp: csharp,
requestSnippetGenerator_ecma5: ecma5,
requestSnippetGenerator_java: javaOkhttp,
requestSnippetGenerator_python: python
}
};
};
global.SnippetGeneratorPlugin = SnippetGeneratorPlugin;
global.SnippetGenerators = {
curlBash, curlPwsh, curlCmd, csharp, ecma5, javaOkhttp, python
};
})(window);
@@ -0,0 +1,314 @@
/* ─────────────────────────────────────────────────────────────────────────── */
/* DJB Swagger Testbed overlay */
/* - 기본 라이브 응답 표(.live-responses-table) 숨김 (스니펫은 유지) */
/* - 실행한 operation 안에 세로 적층 커스텀 응답 패널(.djb-response-grid) */
/* - D2Coding 우선 monospace fallback */
/* 포탈 통합용. PoC(rinjae-works/swagger-poc/css/overlay.css) 에서 응답 파트만 */
/* 발췌·클래스화(단일 ID → 다중 op 대응). */
/* ─────────────────────────────────────────────────────────────────────────── */
/* D2Coding 은 시스템에 설치된 경우에만 사용 (local()). 미설치 시 fallback 체인 사용. */
@font-face {
font-family: "D2Coding";
src: local("D2Coding"),
local("D2Coding-Regular"),
local("D2Coding ligature");
font-display: swap;
}
:root {
--djb-mono: "D2Coding", "Menlo", "Consolas", "Courier New", monospace;
--djb-border: #d0d7de;
--djb-bg: #ffffff;
--djb-bg-alt: #f6f8fa;
--djb-text: #1f2328;
--djb-text-muted: #6e7781;
--djb-ok: #1f883d;
--djb-warn: #bf8700;
--djb-err: #cf222e;
--djb-accent: #0969da;
}
/* ─── Swagger UI 기본 응답영역: 문서화 응답 샘플만 노출 ────────────────────────
응답 영역(.responses-wrapper)은 표시하되(= 상태코드별 응답 예제/스키마 = "응답 샘플"),
실행 결과 라이브 블록은 숨긴다:
- 요청 스니펫(.request-snippets) → 상시 다크 패널(#djb-snippet-panel)로 대체
- 라이브 응답(Curl/Request URL/Server response + .live-responses-table)
= .responses-inner 의 직접 자식 <div> → 커스텀 그리드(.djb-response-grid)로 대체
문서화 응답 예제는 .responses-inner > table.responses-table 이라 <div> 숨김과 구분됨.
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 샘플(표) → 응답 그리드. */
.swagger-ui .request-snippets { display: none !important; }
.swagger-ui .responses-inner > div { display: none !important; }
.swagger-ui .live-responses-table { display: none !important; }
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
.swagger-ui #djb-snippet-panel {
margin: 16px;
padding: 12px 16px 16px;
background: var(--djb-bg);
border: 1px solid var(--djb-border);
border-radius: 8px;
}
.swagger-ui #djb-snippet-panel .djb-sn-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.swagger-ui #djb-snippet-panel .djb-sn-header h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--djb-text);
}
.swagger-ui #djb-snippet-panel .djb-sn-hint {
color: var(--djb-text-muted);
font-size: 11px;
}
.swagger-ui #djb-snippet-panel .djb-sn-copy {
margin-left: auto;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
background: var(--djb-bg-alt);
border: 1px solid var(--djb-border);
border-radius: 4px;
cursor: pointer;
color: var(--djb-text);
}
.swagger-ui #djb-snippet-panel .djb-sn-copy:hover { background: #eaeef2; }
.swagger-ui #djb-snippet-panel .djb-sn-tabs {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
border-bottom: 1px solid var(--djb-border);
}
.swagger-ui #djb-snippet-panel .djb-sn-tab {
background: transparent;
border: 1px solid transparent;
border-bottom: none;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--djb-text-muted);
cursor: pointer;
border-radius: 4px 4px 0 0;
white-space: nowrap;
}
.swagger-ui #djb-snippet-panel .djb-sn-tab:hover { color: var(--djb-text); }
.swagger-ui #djb-snippet-panel .djb-sn-tab.active {
color: var(--djb-accent);
border-color: var(--djb-border);
background: var(--djb-bg);
margin-bottom: -1px;
}
.swagger-ui #djb-snippet-panel .djb-sn-code {
margin: 0;
padding: 12px;
background: #0d1117;
color: #e6edf3;
border-radius: 6px;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-family: var(--djb-mono);
font-size: 12.5px;
line-height: 1.55;
}
/* 한글 라벨이 길어도 깨지지 않도록 */
.swagger-ui .opblock .opblock-summary-path,
.swagger-ui .opblock-tag,
.swagger-ui table thead tr th,
.swagger-ui .parameter__name,
.swagger-ui .parameter__type {
word-break: keep-all;
}
/* ─── 커스텀 응답 그리드: 헤더 → 본문 → 메타 세로 적층 (opblock 마다 1개) ── */
.swagger-ui .djb-response-grid {
margin: 16px;
padding: 16px;
background: var(--djb-bg);
border: 1px solid var(--djb-border);
border-radius: 8px;
display: grid;
grid-template-columns: 1fr;
grid-template-areas:
"headers"
"body"
"meta";
gap: 12px;
color: var(--djb-text);
}
.swagger-ui .djb-response-grid.djb-error { border-color: var(--djb-err); background: #fff8f8; }
.swagger-ui .djb-response-grid .cell-headers { grid-area: headers; min-width: 0; }
.swagger-ui .djb-response-grid .cell-body { grid-area: body; min-width: 0; }
.swagger-ui .djb-response-grid .cell-meta {
grid-area: meta;
display: flex;
align-items: center;
gap: 16px;
padding-top: 8px;
border-top: 1px dashed var(--djb-border);
}
.swagger-ui .djb-response-grid .cell-title {
font-size: 12px;
font-weight: 700;
color: var(--djb-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 6px;
}
.swagger-ui .djb-response-grid .empty {
color: var(--djb-text-muted);
font-size: 13px;
padding: 8px 0;
}
/* Header 테이블 */
.swagger-ui .djb-response-grid .hdr-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.swagger-ui .djb-response-grid .hdr-table th,
.swagger-ui .djb-response-grid .hdr-table td {
border-bottom: 1px solid var(--djb-border);
padding: 6px 8px;
text-align: left;
vertical-align: top;
word-break: break-all;
}
.swagger-ui .djb-response-grid .hdr-table thead th {
background: var(--djb-bg-alt);
font-weight: 600;
}
.swagger-ui .djb-response-grid .hdr-table td.hdr-name {
font-weight: 600;
color: var(--djb-text);
width: 40%;
}
.swagger-ui .djb-response-grid .hdr-table td.hdr-val {
font-family: var(--djb-mono);
font-size: 12px;
color: var(--djb-text);
}
/* Body 탭 */
.swagger-ui .djb-response-grid .body-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--djb-border);
margin-bottom: 8px;
}
.swagger-ui .djb-response-grid .body-tab {
background: transparent;
border: 1px solid transparent;
border-bottom: none;
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
color: var(--djb-text-muted);
cursor: pointer;
border-radius: 4px 4px 0 0;
}
.swagger-ui .djb-response-grid .body-tab:hover { color: var(--djb-text); }
.swagger-ui .djb-response-grid .body-tab.active {
color: var(--djb-accent);
border-color: var(--djb-border);
background: var(--djb-bg);
margin-bottom: -1px;
}
.swagger-ui .djb-response-grid .body-panels {
position: relative;
min-height: 80px;
}
.swagger-ui .djb-response-grid .body-panel {
display: none;
margin: 0;
padding: 12px;
background: #0d1117;
color: #e6edf3;
border-radius: 6px;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 12.5px;
line-height: 1.55;
}
.swagger-ui .djb-response-grid .body-panel.active { display: block; }
.swagger-ui .djb-response-grid .mono { font-family: var(--djb-mono); }
/* Meta 칩 / 시간 */
.swagger-ui .djb-response-grid .status-chip {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
font-family: var(--djb-mono);
color: #fff;
}
.swagger-ui .djb-response-grid .status-chip.ok { background: var(--djb-ok); }
.swagger-ui .djb-response-grid .status-chip.warn { background: var(--djb-warn); }
.swagger-ui .djb-response-grid .status-chip.err { background: var(--djb-err); }
.swagger-ui .djb-response-grid .status-chip.neutral { background: var(--djb-text-muted); }
.swagger-ui .djb-response-grid .time {
font-family: var(--djb-mono);
font-size: 12px;
color: var(--djb-text-muted);
}
/* ─── 검증 실패 목록 (djb-swagger-validator.js) ─────────────────────────────── */
.swagger-ui .djb-response-grid .verr-list {
list-style: none;
margin: 0;
padding: 0;
}
.swagger-ui .djb-response-grid .verr-item {
padding: 8px 10px;
margin-bottom: 6px;
background: #fff8f8;
border: 1px solid #ffd7d5;
border-left: 3px solid var(--djb-err);
border-radius: 4px;
font-size: 13px;
line-height: 1.5;
}
.swagger-ui .djb-response-grid .verr-path {
font-family: var(--djb-mono);
font-weight: 700;
color: var(--djb-err);
}
.swagger-ui .djb-response-grid .verr-msg { color: var(--djb-text); }
.swagger-ui .djb-response-grid .verr-val {
display: inline-block;
margin-top: 2px;
padding: 1px 6px;
background: var(--djb-bg-alt);
border: 1px solid var(--djb-border);
border-radius: 3px;
color: var(--djb-text-muted);
font-size: 12px;
}
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
.swagger-ui #djb-snippet-panel .djb-sn-code::selection,
.swagger-ui #djb-snippet-panel .djb-sn-code ::selection,
.swagger-ui .djb-response-grid .body-panel::selection,
.swagger-ui .djb-response-grid .body-panel ::selection {
background: #2f5c9e;
color: #ffffff;
}
@@ -0,0 +1,202 @@
/*!
* DjbSwaggerValidator — Execute 전에 요청 파라미터(header/query)와 요청 본문(body)을
* OpenAPI 스키마로 검증하는 경량 JSON Schema 검증기.
* (PoC rinjae-works/swagger-poc/js/validator.js 를 포탈용으로 이식)
*
* 지원 키워드: type, required, properties, items, $ref, maxLength, minLength,
* maximum, minimum, pattern, enum
* 지원 type: string, integer, number, object, array, boolean
*
* 사용:
* var r = DjbSwaggerValidator.validateOperation(operation, parsedRequest, rootSpec);
* if (!r.ok) console.log(r.errors); // [{ path, message, value }]
*/
(function (global) {
"use strict";
function resolveRef(spec, ref) {
if (ref.indexOf("#/") !== 0) return null;
var parts = ref.slice(2).split("/");
var cur = spec;
for (var i = 0; i < parts.length; i++) {
if (cur == null) return null;
cur = cur[decodeURIComponent(parts[i]).replace(/~1/g, "/").replace(/~0/g, "~")];
}
return cur || null;
}
function deref(spec, schema, seen) {
if (!schema || typeof schema !== "object") return schema;
if (schema.$ref) {
if (seen && seen.indexOf(schema.$ref) !== -1) return {};
var next = resolveRef(spec, schema.$ref);
var seen2 = (seen || []).concat([schema.$ref]);
return deref(spec, next, seen2);
}
return schema;
}
function typeMatches(value, type) {
if (value === null || value === undefined) return false;
switch (type) {
case "string": return typeof value === "string";
case "integer": return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
case "number": return typeof value === "number" && !isNaN(value);
case "boolean": return typeof value === "boolean";
case "array": return Array.isArray(value);
case "object": return typeof value === "object" && !Array.isArray(value);
default: return true;
}
}
function pushErr(errors, path, message, value) {
errors.push({ path: path || "/", message: message, value: value });
}
function checkSchema(spec, schema, value, path, errors) {
if (!schema) return;
schema = deref(spec, schema);
if (value === undefined || value === null) {
// required 는 상위 object 단계에서 확인. 여기서는 통과.
return;
}
if (schema.type && !typeMatches(value, schema.type)) {
var actual = typeof value === "object" ? (Array.isArray(value) ? "array" : "object") : typeof value;
pushErr(errors, path, '타입이 "' + schema.type + '" 이어야 합니다 (현재: ' + actual + ")", value);
return;
}
if (Array.isArray(schema.enum) && schema.enum.indexOf(value) === -1) {
pushErr(errors, path, "허용된 값이 아닙니다 (허용: " + schema.enum.join(", ") + ")", value);
}
if (schema.type === "string" || typeof value === "string") {
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
pushErr(errors, path, "최대 " + schema.maxLength + " 자까지 허용됩니다 (현재: " + value.length + "자)", value);
}
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
pushErr(errors, path, "최소 " + schema.minLength + " 자 이상이어야 합니다 (현재: " + value.length + "자)", value);
}
if (schema.pattern) {
try {
if (!new RegExp(schema.pattern).test(value)) {
pushErr(errors, path, "형식이 올바르지 않습니다 (pattern: " + schema.pattern + ")", value);
}
} catch (e) { /* invalid regex in spec — skip */ }
}
}
if (schema.type === "integer" || schema.type === "number" || typeof value === "number") {
if (typeof schema.maximum === "number" && value > schema.maximum) {
pushErr(errors, path, "최대값 " + schema.maximum + " 이하여야 합니다 (현재: " + value + ")", value);
}
if (typeof schema.minimum === "number" && value < schema.minimum) {
pushErr(errors, path, "최소값 " + schema.minimum + " 이상이어야 합니다 (현재: " + value + ")", value);
}
}
if (schema.type === "object" && schema.properties) {
var requiredList = Array.isArray(schema.required) ? schema.required : [];
requiredList.forEach(function (reqKey) {
if (value[reqKey] === undefined || value[reqKey] === null || value[reqKey] === "") {
pushErr(errors, path + "/" + reqKey, "필수 항목이 누락되었습니다", undefined);
}
});
Object.keys(schema.properties).forEach(function (key) {
if (value[key] !== undefined && value[key] !== null) {
checkSchema(spec, schema.properties[key], value[key], path + "/" + key, errors);
}
});
}
if (schema.type === "array" && schema.items && Array.isArray(value)) {
value.forEach(function (item, idx) {
checkSchema(spec, schema.items, item, path + "/" + idx, errors);
});
}
}
/**
* Swagger UI 의 request 객체에서 header/query/body 를 추출해 operation 정의로 검증.
*
* @param {Object} operation spec.paths[path][method] 객체
* @param {Object} req { url, method, headers, body, ... }
* @param {Object} rootSpec $ref 해석을 위한 전체 OpenAPI 문서(json)
* @returns {{ ok:boolean, errors:Array<{path,message,value}> }}
*/
function validateOperation(operation, req, rootSpec) {
var errors = [];
if (!operation) return { ok: true, errors: errors };
// 1. parameters 검증 (header / query). path 는 Swagger 가 이미 치환 → 건너뜀.
var params = Array.isArray(operation.parameters) ? operation.parameters : [];
for (var i = 0; i < params.length; i++) {
var p = deref(rootSpec, params[i]);
var where = p.in;
var name = p.name;
var schema = p.schema ? deref(rootSpec, p.schema) : { type: "string" };
var value;
if (where === "header") {
var headers = req.headers || {};
var matchKey = Object.keys(headers).filter(function (k) {
return k.toLowerCase() === String(name).toLowerCase();
})[0];
value = matchKey ? headers[matchKey] : undefined;
} else if (where === "query") {
try {
var u = new URL(req.url, "http://_local");
value = u.searchParams.get(name);
} catch (e) { value = undefined; }
} else {
continue; // path/cookie 등은 건너뜀
}
if (p.required && (value === undefined || value === "" || value === null)) {
pushErr(errors, (where === "query" ? "query" : "header") + "[" + name + "]",
(where === "query" ? "필수 쿼리 파라미터가" : "필수 헤더가") + " 누락되었습니다", undefined);
continue;
}
if (value !== undefined && value !== null && value !== "") {
var coerced = (schema.type === "integer" || schema.type === "number") ? Number(value) : String(value);
checkSchema(rootSpec, schema, coerced, (where === "query" ? "query" : "header") + "[" + name + "]", errors);
}
}
// 2. requestBody 검증
if (operation.requestBody) {
var rb = deref(rootSpec, operation.requestBody);
if (rb.required && (req.body === undefined || req.body === null || req.body === "")) {
pushErr(errors, "body", "요청 본문이 비어 있습니다", undefined);
} else if (req.body) {
var contentTypes = Object.keys(rb.content || {});
var ctHeader = (req.headers && (req.headers["Content-Type"] || req.headers["content-type"])) || contentTypes[0];
var matchedCt = contentTypes.filter(function (c) {
return ctHeader && ctHeader.toLowerCase().indexOf(c.split(";")[0].toLowerCase()) !== -1;
})[0] || contentTypes[0];
var bodySchema = (matchedCt && rb.content[matchedCt]) ? deref(rootSpec, rb.content[matchedCt].schema) : null;
var parsed = req.body;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); }
catch (e) {
pushErr(errors, "body", "JSON 파싱 실패: " + e.message, req.body);
return { ok: false, errors: errors };
}
}
if (bodySchema) {
checkSchema(rootSpec, bodySchema, parsed, "body", errors);
}
}
}
return { ok: errors.length === 0, errors: errors };
}
global.DjbSwaggerValidator = {
validateOperation: validateOperation,
_internal: { resolveRef: resolveRef, deref: deref, checkSchema: checkSchema }
};
})(window);
@@ -1,7 +1,7 @@
html {
box-sizing: border-box;
overflow: -moz-scrollbars-vertical;
height: 100%;
overflow-y: scroll;
}
*,
@@ -12,4 +12,5 @@ html {
body {
margin: 0;
background: #fafafa;
}
@@ -1,79 +1,6 @@
<!doctype html>
<html lang="en-US">
<head>
<title>Swagger UI: OAuth2 Redirect</title>
</head>
<body>
<script>
'use strict';
function run () {
var oauth2 = window.opener.swaggerUIRedirectOauth2;
var sentState = oauth2.state;
var redirectUrl = oauth2.redirectUrl;
var isValid, qp, arr;
if (/code|token|error/.test(window.location.hash)) {
qp = window.location.hash.substring(1).replace('?', '&');
} else {
qp = location.search.substring(1);
}
arr = qp.split("&");
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
qp = qp ? JSON.parse('{' + arr.join() + '}',
function (key, value) {
return key === "" ? value : decodeURIComponent(value);
}
) : {};
isValid = qp.state === sentState;
if ((
oauth2.auth.schema.get("flow") === "accessCode" ||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
oauth2.auth.schema.get("flow") === "authorization_code"
) && !oauth2.auth.code) {
if (!isValid) {
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "warning",
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
});
}
if (qp.code) {
delete oauth2.state;
oauth2.auth.code = qp.code;
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
} else {
let oauthErrorMsg;
if (qp.error) {
oauthErrorMsg = "["+qp.error+"]: " +
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
(qp.error_uri ? "More info: "+qp.error_uri : "");
}
oauth2.errCb({
authId: oauth2.auth.name,
source: "auth",
level: "error",
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
});
}
} else {
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
}
window.close();
}
if (document.readyState !== 'loading') {
run();
} else {
document.addEventListener('DOMContentLoaded', function () {
run();
});
}
</script>
<script src="oauth2-redirect.js"></script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -602,6 +602,130 @@
}
}
// Testbed 선택 패널 (DJPGPT0001)
.testbed-app-panel {
margin: $spacing-sm 0 $spacing-lg;
padding: $spacing-lg;
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
.testbed-app-panel__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: $spacing-xs $spacing-sm;
margin-bottom: $spacing-md;
}
.testbed-app-panel__title {
position: relative;
padding-left: $spacing-md;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $text-dark;
letter-spacing: -0.01em;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 15px;
background: $primary-blue;
border-radius: 2px;
}
}
.testbed-app-panel__desc {
font-size: $font-size-sm;
color: $text-gray;
}
.testbed-app-field {
position: relative;
max-width: 380px;
&::after {
content: "";
position: absolute;
right: $spacing-md;
top: 50%;
width: 9px;
height: 9px;
margin-top: -6px;
border-right: 2px solid $text-gray;
border-bottom: 2px solid $text-gray;
transform: rotate(45deg);
pointer-events: none;
}
}
#apps {
width: 100%;
height: 46px;
padding: 0 42px 0 $input-padding-x;
font-family: $font-family-primary;
font-size: $font-size-sm;
color: $text-dark;
background: $gray-bg;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: $transition-fast;
&:hover:not(:disabled) {
border-color: $text-light;
background: $white;
}
&:focus {
outline: none;
background: $white;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.14);
}
&:disabled {
color: $text-light;
background: $gray-bg;
cursor: not-allowed;
}
}
.testbed-app-notice {
margin: $spacing-md 0 0;
padding: 11px $spacing-md;
font-size: $font-size-sm;
line-height: $line-height-normal;
color: $text-gray;
background: $light-bg;
border: 1px solid rgba($primary-blue, 0.18);
border-left: 3px solid $primary-blue;
border-radius: $border-radius-sm;
&::before {
content: "";
color: $primary-blue;
font-weight: $font-weight-bold;
}
}
@media (max-width: $breakpoint-sm) {
padding: $spacing-md;
.testbed-app-field {
max-width: 100%;
}
}
}
// API Overview Card (Flat Style)
.api-overview-card {
background: transparent;