Files
eapim-admin/WebContent/js/djb/apispec/app.js
T
Rinjae 8ccf52fd66 - JSP: OpenAPI Spec 모달 처리 개선 - 동적 크기 조절 및 스타일 추가
- Service: GET/DELETE 요청 바디 항목→쿼리 파라미터 매핑 로직 추가
- JS: 요청 정보 진입 시 탭 자동 전환 로직(firstReqTabWithData) 구현
2026-07-13 15:30:08 +09:00

2029 lines
107 KiB
JavaScript

/* DJB OpenAPI Editor POC — app.js
* vanilla JS, 단일 상태 + 함수형 렌더
*/
(function () {
'use strict';
// ============================================================
// 1. 상태
// ============================================================
const STEPS = [
{ id: 1, title: '기본정보', hint: 'API 메타 정보·엔드포인트·인증/보안을 확인·입력합니다.' },
{ id: 2, title: '요청 정보', hint: '요청 Header/Parameter/Body 와 API 설명(HTML)을 작성합니다.' },
{ id: 3, title: '응답 정보', hint: '응답 Header/Body 와 API 설명(HTML)을 작성합니다.' },
{ id: 4, title: '요청/응답 샘플', hint: '요청/응답 예제를 입력합니다.' },
{ id: 5, title: '포탈 게시 정보', hint: '개발자포탈 공개 설정(공개 여부/권한/법인/응답유형)을 지정합니다.' }
];
const state = {
step: 1,
showPreview: true,
previewTab: 'swagger', // 'swagger' | 'yaml-ro' | 'yaml-edit'
data: deepClone(window.SAMPLE_DATA),
step3Section: 'body', // 'params' | 'body' | 'response' | 'components'
responseStatusTab: '200',
components: [], // 재사용 스키마(POC: 빈 상태)
};
// ============================================================
// 2. 유틸
// ============================================================
function deepClone(o) { return JSON.parse(JSON.stringify(o)); }
function $(sel, ctx) { return (ctx || document).querySelector(sel); }
function $$(sel, ctx) { return Array.from((ctx || document).querySelectorAll(sel)); }
function el(tag, attrs, children) {
const e = document.createElement(tag);
if (attrs) for (const k in attrs) {
if (k === 'class') e.className = attrs[k];
else if (k === 'html') e.innerHTML = attrs[k];
else if (k.startsWith('on') && typeof attrs[k] === 'function') e.addEventListener(k.slice(2), attrs[k]);
else e.setAttribute(k, attrs[k]);
}
if (children) (Array.isArray(children) ? children : [children]).forEach(c => {
if (c == null) return;
e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
});
return e;
}
function debounce(fn, ms) { let t; return function () { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), ms); }; }
function toast(msg, kind) {
const t = $('#toast');
t.textContent = msg;
t.className = 'fixed bottom-5 left-1/2 -translate-x-1/2 px-4 py-2 rounded text-white text-sm shadow-lg z-50 ' +
(kind === 'error' ? 'bg-red-600' : kind === 'warn' ? 'bg-amber-600' : 'bg-slate-900');
t.classList.remove('hidden');
setTimeout(() => t.classList.add('hidden'), 2400);
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
// CrossScriptingFilter 가 엔티티(&#40; 등)로 치환한 값 복원. input value 로만 사용 → 안전, 멱등.
function decodeEntities(s) { if (s == null || s === '') return s; var t = document.createElement('textarea'); t.innerHTML = String(s); return t.value; }
// 잠금 필드 표준 렌더
function lockedInput(value, opts) {
opts = opts || {};
const cls = 'w-full px-3 py-1.5 pr-8 text-sm rounded border border-slate-200 bg-slate-100 text-slate-600 cursor-not-allowed';
return `
<div class="relative" title="API 게이트웨이에서 자동 동기화된 값입니다">
<input type="text" value="${escapeHtml(value)}" readonly tabindex="-1" class="${cls} ${opts.extra || ''}" />
<span class="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 text-xs">🔒</span>
</div>`;
}
function input(value, dataAttr, opts) {
opts = opts || {};
const cls = 'w-full px-3 py-1.5 text-sm rounded border border-slate-300 bg-white text-slate-800 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500';
const type = opts.type || 'text';
return `<input type="${type}" value="${escapeHtml(value)}" ${dataAttr} class="${cls} ${opts.extra || ''}" placeholder="${escapeHtml(opts.placeholder || '')}" />`;
}
function textarea(value, dataAttr, opts) {
opts = opts || {};
return `<textarea ${dataAttr} rows="${opts.rows || 3}"
class="w-full px-3 py-2 text-sm rounded border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="${escapeHtml(opts.placeholder || '')}">${escapeHtml(value)}</textarea>`;
}
function selectInput(value, options, dataAttr, opts) {
opts = opts || {};
const optHtml = options.map(o => {
const v = typeof o === 'string' ? o : o.value;
const l = typeof o === 'string' ? o : o.label;
return `<option value="${escapeHtml(v)}" ${v === value ? 'selected' : ''}>${escapeHtml(l)}</option>`;
}).join('');
const cls = 'w-full px-2 py-1.5 text-sm rounded border border-slate-300 bg-white focus:outline-none focus:ring-2 focus:ring-brand-500';
const locked = opts.locked ? 'disabled' : '';
const lockedCls = opts.locked ? 'bg-slate-100 text-slate-600 cursor-not-allowed' : '';
return `<select ${dataAttr} ${locked} class="${cls} ${lockedCls} ${opts.extra || ''}">${optHtml}</select>`;
}
function checkbox(checked, dataAttr, opts) {
opts = opts || {};
const locked = opts.locked ? 'disabled' : '';
return `<input type="checkbox" ${checked ? 'checked' : ''} ${dataAttr} ${locked}
class="w-4 h-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500 ${opts.locked ? 'cursor-not-allowed' : ''}" />`;
}
// ============================================================
// 3. OpenAPI 스펙 빌더 (state.data → OpenAPI 3.1 객체)
// ============================================================
function buildOpenApiSpec() {
const d = state.data;
syncResponseExamplesFromSchema(); // 응답 예제 = 응답 정보(스키마·예제값) 파생 — 저장/미리보기 일관
const spec = {
openapi: (d.__openapi || '3.1.0'),
info: {
title: d.info.title.value,
version: d.info.version.value
},
paths: {},
components: { securitySchemes: {} },
tags: d.tags.map(t => ({ name: t.name, description: t.description }))
};
// 설명(info.description)은 API 소개 페이지 전용 → spec 에는 미포함(저장은 description 컬럼에 별도).
if (d.info.termsOfService && d.info.termsOfService.value) spec.info.termsOfService = d.info.termsOfService.value;
const contact = {};
if (d.info.contact.name.value) contact.name = d.info.contact.name.value;
if (d.info.contact.email.value) contact.email = d.info.contact.email.value;
if (d.info.contact.url.value) contact.url = d.info.contact.url.value;
if (Object.keys(contact).length) spec.info.contact = contact;
const license = {};
if (d.info.license.name.value) license.name = d.info.license.name.value;
if (d.info.license.url.value) license.url = d.info.license.url.value;
if (license.name) spec.info.license = license;
if (d.externalDocs && d.externalDocs.url.value) {
spec.externalDocs = { url: d.externalDocs.url.value };
if (d.externalDocs.description.value) spec.externalDocs.description = d.externalDocs.description.value;
}
// 경로 + 오퍼레이션
const op = d.operation;
var _ifId = (window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value;
const operation = {
tags: (d.tags || []).map(t => t.name), // 오퍼레이션 태그 = 최상위 tags(단일 소스) → tags 삭제 시 Swagger 그룹도 사라짐
summary: (d.info.title && d.info.title.value) || op.summary.value || undefined,
description: op.description.value || undefined,
operationId: _ifId,
deprecated: op.deprecated.value || undefined,
parameters: d.parameters.map(p => ({
in: p.in,
name: p.name.value,
required: !!p.required.value,
description: p.description.value || undefined,
schema: schemaRowToSchema(p),
example: p.example.value || undefined
})),
requestBody: {
required: !!d.requestBody.required.value,
content: {
[d.requestBody.mediaType]: {
schema: schemaArrayToObject(d.requestBody.schema),
example: tryParseJson(d.examples.request[d.requestBody.mediaType].default)
}
}
},
responses: {}
};
Object.keys(d.responses).forEach(code => {
const r = d.responses[code];
const ex = (d.examples.response && d.examples.response[code]) || { body: '', headers: [] };
const content = {};
if (r.schema && r.schema.length) content['application/json'] = { schema: schemaArrayToObject(r.schema) };
if (ex.body) {
content['application/json'] = content['application/json'] || {};
content['application/json'].example = tryParseJson(ex.body);
}
operation.responses[code] = {
description: r.description.value || '',
...(Object.keys(content).length ? { content } : {})
};
// 상태코드별 응답 헤더 세트
if (ex.headers && ex.headers.length) {
operation.responses[code].headers = {};
ex.headers.forEach(h => {
if (!h.name) return;
operation.responses[code].headers[h.name] = { description: h.description, schema: { type: h.type }, example: h.example };
});
}
});
// 경로(op.path.value)에는 어댑터경로가 이미 baking 됨(백엔드 applyAutoRules). 서버에는 응답유형별 호스트만 붙인다.
// - gw : djb.gateway.base-url (PortalProperty) → [gw호스트 + 어댑터경로 + 경로]
// - mock : Mock Server URL → [mock호스트 + 어댑터경로 + 경로]
// - 그 외 : 호스트 없음 → [어댑터경로 + 경로]
var _rt = (d.portal && d.portal.responseType) || 'sample';
var _host;
if (_rt === 'gw') _host = (window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310';
else if (_rt === 'mock') _host = (d.portal && d.portal.mockUrl) || '';
else _host = '';
if (_host) spec.servers = [{ url: _host }];
spec.paths[op.path.value] = { [op.method.value.toLowerCase()]: operation };
// 보안 스킴
d.securitySchemes.forEach(s => {
const ss = { type: s.type };
if (s.description && s.description.value) ss.description = s.description.value;
if (s.type === 'apiKey') { ss.name = s.name.value; ss.in = s.in.value; }
if (s.type === 'http') { ss.scheme = s.scheme.value; if (s.bearerFormat && s.bearerFormat.value) ss.bearerFormat = s.bearerFormat.value; }
if (s.type === 'oauth2') { ss.flows = s.flows || {}; }
if (s.type === 'openIdConnect') { ss.openIdConnectUrl = s.openIdConnectUrl.value; }
spec.components.securitySchemes[s.key] = ss;
});
if (d.globalSecurity && d.globalSecurity.length) {
spec.security = d.globalSecurity.map(k => ({ [k]: [] }));
}
// (태그명=API타이틀 강제 override 제거) — tags/operation.tags 는 state.data 값을 그대로 사용해
// 스펙 편집에서 변경한 tags 가 원복되지 않도록 함. 자동생성 시 태그=API명 은 백엔드(generateSpec)에서 적용.
return spec;
}
function tryParseJson(s) { try { return JSON.parse(s); } catch (e) { return s; } }
function schemaRowToSchema(row) {
const s = { type: row.type.value };
if (row.format && row.format.value) s.format = row.format.value;
if (row.maxLength && row.maxLength.value) s.maxLength = Number(row.maxLength.value);
if (row.pattern && row.pattern.value) s.pattern = row.pattern.value;
if (row.description && row.description.value) s.description = row.description.value;
if (row.example && row.example.value !== '') s.example = coerceExample(row.type.value, row.example.value);
if (row.enumStr && row.enumStr.value) s.enum = row.enumStr.value.split(',').map(v => v.trim()).filter(Boolean);
if (row.name && row.name.locked) s['x-djb-gw'] = true; // GW 잠금 마커 보존(저장→로드 시 잠금 유지)
return s;
}
function coerceExample(type, v) {
if (type === 'integer' || type === 'number') { const n = Number(v); return isNaN(n) ? v : n; }
if (type === 'boolean') return v === 'true' || v === true;
return v;
}
function schemaArrayToObject(arr) {
if (!arr || !arr.length) return { type: 'object' };
const obj = { type: 'object', properties: {}, required: [] };
arr.forEach(row => {
const name = row.name.value;
if (!name) return;
if (row.type.value === 'object' && row.children) {
obj.properties[name] = schemaArrayToObject(row.children);
if (row.description && row.description.value) obj.properties[name].description = row.description.value;
if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true;
} else if (row.type.value === 'array') {
obj.properties[name] = {
type: 'array',
items: { type: (row.itemsType && row.itemsType.value) || 'string' }
};
if (row.description && row.description.value) obj.properties[name].description = row.description.value;
if (row.name && row.name.locked) obj.properties[name]['x-djb-gw'] = true;
} else {
obj.properties[name] = schemaRowToSchema(row);
}
if (row.required && row.required.value) obj.required.push(name);
});
if (!obj.required.length) delete obj.required;
return obj;
}
// ============================================================
// 4. 스텝퍼 렌더
// ============================================================
function renderStepper() {
const ol = $('#stepper');
ol.innerHTML = '';
STEPS.forEach(s => {
const isActive = state.step === s.id;
const isDone = state.step > s.id;
const li = el('li', {
'class': 'step-item flex items-center gap-3 py-3 px-3 cursor-pointer select-none',
'data-step': s.id
});
const circle = el('span', {
'class': 'flex items-center justify-center w-7 h-7 rounded-full text-xs font-semibold ' +
(isActive ? 'bg-brand-600 text-white' :
isDone ? 'bg-brand-100 text-brand-700 border border-brand-300' :
'bg-slate-100 text-slate-500 border border-slate-200')
}, isDone ? '✓' : String(s.id));
const label = el('div', {}, [
el('div', { 'class': 'text-sm font-medium ' + (isActive ? 'text-slate-900' : 'text-slate-600') }, s.title),
el('div', { 'class': 'text-[11px] text-slate-400' }, isActive ? '진행중' : (isDone ? '완료' : '대기'))
]);
li.appendChild(circle); li.appendChild(label);
li.addEventListener('click', () => goStep(s.id));
ol.appendChild(li);
});
$('#stepper-progress').style.width = ((state.step / STEPS.length) * 100).toFixed(2) + '%';
$('#form-step-num').textContent = String(state.step);
$('#form-step-title').textContent = STEPS[state.step - 1].title;
$('#footer-hint').textContent = STEPS[state.step - 1].hint;
$('#btn-prev').disabled = state.step === 1;
$('#btn-next').textContent = state.step === STEPS.length ? '완료' : '다음 ▶';
}
function goStep(n) {
if (n < 1 || n > STEPS.length) return;
state.step = n;
if (n === 2) state.reqInfoTab = firstReqTabWithData(); // 요청 정보: 값 있는 탭(Header→Parameter→Body 순) 먼저
if (n === 3) state.resInfoTab = 'body';
renderStepper();
renderForm();
if (state.step === 2 || state.step === 3) {
setPreviewVisibility(false); // 요청/응답 정보(넓은 테이블): 우측 미리보기 패널 숨김
} else {
setPreviewVisibility(true);
restoreNormalPreviewLayout();
}
}
// ============================================================
// 5. Form 렌더 디스패치
// ============================================================
function renderForm() {
const root = $('#form-content');
root.innerHTML = '';
switch (state.step) {
case 1: renderStep1(root); break; // 기본정보(+엔드포인트+인증/보안)
case 2: renderReqInfo(root); break; // 요청 정보 (Header/Parameter/Body/API 설명)
case 3: renderResInfo(root); break; // 응답 정보 (Header/Body/API 설명)
case 4: renderStep5(root); break; // 요청/응답 샘플
case 5: renderStep6(root); break; // 포탈 게시 정보
}
}
// ----- helpers for section blocks -----
function sectionTitle(title, hint) {
return `<div class="mb-2"><h3 class="text-sm font-semibold text-slate-800">${escapeHtml(title)}</h3>${
hint ? `<p class="text-xs text-slate-500 mt-0.5">${escapeHtml(hint)}</p>` : ''}</div>`;
}
function fieldRow(label, controlHtml, opts) {
opts = opts || {};
return `
<div class="grid grid-cols-12 items-start gap-3 mb-3">
<label class="col-span-3 pt-1.5 text-sm text-slate-600 ${opts.required ? 'font-medium' : ''}">
${escapeHtml(label)}${opts.required ? ' <span class="text-red-500">*</span>' : ''}
${opts.locked ? '<span class="ml-1 text-slate-400 text-xs">🔒</span>' : ''}
</label>
<div class="col-span-9">${controlHtml}${opts.hint ? `<p class="text-xs text-slate-400 mt-1">${escapeHtml(opts.hint)}</p>` : ''}</div>
</div>`;
}
function card(html) {
return `<div class="border border-slate-200 rounded-lg bg-white p-4 mb-4">${html}</div>`;
}
// ============================================================
// 6. Step 1 — 기본정보
// ============================================================
function renderStep1(root) {
const i = state.data.info;
const op = state.data.operation;
const methodColor = {
GET: 'bg-emerald-100 text-emerald-700 border-emerald-300',
POST: 'bg-blue-100 text-blue-700 border-blue-300',
PUT: 'bg-amber-100 text-amber-700 border-amber-300',
DELETE: 'bg-red-100 text-red-700 border-red-300'
}[op.method.value] || 'bg-slate-100 text-slate-700 border-slate-300';
root.innerHTML = `
${card(
sectionTitle('API 정보', '게이트웨이가 제공한 식별 정보와 사용자가 작성하는 설명을 분리해 표시합니다.') +
`<div class="flex items-center gap-3 mb-4 p-3 bg-slate-50 border border-slate-200 rounded">
<span class="px-2.5 py-1 text-xs font-bold rounded border ${methodColor}">${escapeHtml(op.method.value)}</span>
<code class="text-sm font-mono text-slate-800">${escapeHtml(op.path.value)}</code>
<span class="text-xs text-slate-400 ml-auto">🔒 게이트웨이에서 자동 동기화</span>
</div>` +
fieldRow('Operation ID', lockedInput((window.DJB_CTX && window.DJB_CTX.eaiSvcName) || op.operationId.value), { locked: true }) +
fieldRow('API 타이틀', input(i.title.value, 'data-path="info.title.value"', { required: true }), { required: true }) +
fieldRow('버전', input(i.version.value, 'data-path="info.version.value"', { required: true }), { required: true }) +
fieldRow('간단 설명', input((i.summary && i.summary.value) || '', 'data-path="info.summary.value"', { required: true, placeholder: '태그 설명으로 사용됩니다' }), { required: true }) +
fieldRow('상세 설명', textarea(op.description.value, 'data-path="operation.description.value"', { rows: 4, placeholder: '동작, 비즈니스 규칙, 주의사항' }) + '<div class="text-xs text-slate-400 mt-1">Markdown 문법 사용 가능</div>')
)}
${card(
sectionTitle('외부 문서 링크') +
fieldRow('설명', input(state.data.externalDocs.description.value, 'data-path="externalDocs.description.value"')) +
fieldRow('URL', input(state.data.externalDocs.url.value, 'data-path="externalDocs.url.value"', { placeholder: 'https://...' }))
)}
${renderAuthSection()}
`;
}
// 설명 = Summernote 리치 에디터(#info-desc-editor). Step6(포탈 게시 정보)에서 렌더 후 호출.
function initInfoDescEditor() {
if (!(window.jQuery && window.jQuery.fn && window.jQuery.fn.summernote)) return;
var $desc = window.jQuery('#info-desc-editor');
if (!$desc.length) return;
try { $desc.summernote('destroy'); } catch (e) {}
var koOk = window.jQuery.summernote && window.jQuery.summernote.lang && window.jQuery.summernote.lang['ko-KR'];
$desc.summernote({
height: 360,
lang: koOk ? 'ko-KR' : 'en-US',
placeholder: '여기에 내용을 입력하세요.',
toolbar: DJB_SN_TOOLBAR,
callbacks: {
onInit: addEditorContentClass,
onChange: function (contents) { state.data.info.description.value = contents; }
}
});
$desc.summernote('code', state.data.info.description.value || '');
addEditorContentClass();
}
function renderTagChips() {
if (!state.data.tags.length) return '<span class="text-xs text-slate-400">등록된 태그가 없습니다</span>';
return state.data.tags.map((t, i) =>
`<span class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-brand-50 text-brand-700 border border-brand-200">
${escapeHtml(t.name)}${t.description ? ' · ' + escapeHtml(t.description) : ''}
<button data-remove-tag="${i}" class="text-brand-500 hover:text-brand-800 ml-1">✕</button>
</span>`
).join('');
}
// (구 Step 2 엔드포인트는 기본정보 카드로 병합됨)
function renderOpTagSelector(selected) {
return `<div class="flex flex-wrap gap-2">
${state.data.tags.map(t => `
<label class="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full border cursor-pointer
${selected.includes(t.name) ? 'bg-brand-50 border-brand-300 text-brand-700' : 'bg-white border-slate-200 text-slate-600'}">
<input type="checkbox" data-op-tag="${escapeHtml(t.name)}" ${selected.includes(t.name) ? 'checked' : ''} class="w-3 h-3" />
${escapeHtml(t.name)}
</label>
`).join('')}
${state.data.tags.length === 0 ? '<span class="text-xs text-slate-400">먼저 Step 1 에서 태그를 등록하세요</span>' : ''}
</div>`;
}
// ============================================================
// 8. Step 3 — 파라미터 / 스키마 (핵심)
// ============================================================
// 서브 탭 바 (요청/응답 정보 공용)
function subTabBar(tabs, cur, attr) {
return '<div class="flex items-center gap-1 border-b border-slate-200 mb-4">'
+ tabs.map(t => `<button ${attr}="${t.key}" class="px-4 py-2 text-sm rounded-t border-b-2 ${cur === t.key ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600'}">${t.label}</button>`).join('')
+ '</div>';
}
// 요청 정보 진입 시 먼저 보일 서브탭 = 값이 든 첫 탭(Header→Parameter→Body 순).
// 보통 GET 은 Parameter(query), POST 는 Body 에만 값이 있음. 전부 비면 Body 로.
function firstReqTabWithData() {
var params = (state.data && state.data.parameters) || [];
var hasHeader = params.some(function (p) { return p.in === 'header'; });
var hasParam = params.some(function (p) { return p.in === 'query'; });
var hasBody = !!(state.data && state.data.requestBody && state.data.requestBody.schema && state.data.requestBody.schema.length);
if (hasHeader) return 'header';
if (hasParam) return 'param';
if (hasBody) return 'body';
return 'body';
}
// Step 2 — 요청 정보 (Header / Parameter / Body / API 설명(HTML))
function renderReqInfo(root) {
const tab = state.reqInfoTab || 'body';
const tabs = [
{ key: 'header', label: 'Header' },
{ key: 'param', label: 'Parameter' },
{ key: 'body', label: 'Body' },
{ key: 'desc', label: 'API 설명(HTML)' }
];
let body;
if (tab === 'header') body = renderParamsTable('header');
else if (tab === 'param') body = renderParamsTable('param');
else if (tab === 'body') body = renderBodyTable();
else body = card(
sectionTitle('요청 API 설명(HTML)', 'API 소개(설명) 페이지에 표시되는 자료입니다.') +
`<div style="min-width:890px;max-width:900px"><div class="mb-2 flex justify-end"><button id="btn-regen-req-spec" class="px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50">스키마로 재생성</button></div>` +
'<div id="msg-editor"></div></div>'
);
root.innerHTML = subTabBar(tabs, tab, 'data-reqinfo-tab') + body;
if (tab === 'desc') {
var fc = document.getElementById('form-content');
// 카드/탭바/툴바/여백 오버헤드를 제외해 폼 영역에 세로스크롤이 생기지 않는 높이로
var h = Math.max(300, (fc ? fc.clientHeight : 600) - 290);
initSpecEditor('#msg-editor', 'request', h);
}
}
// Step 3 — 응답 정보 (Header / Body / API 설명(HTML))
function renderResInfo(root) {
const tab = state.resInfoTab || 'body';
const tabs = [
{ key: 'header', label: 'Header' },
{ key: 'body', label: 'Body' },
{ key: 'desc', label: 'API 설명(HTML)' }
];
let body;
if (tab === 'header') body = renderResponseHeaderCard();
else if (tab === 'body') body = renderResponseTable();
else body = card(
sectionTitle('응답 API 설명(HTML)', 'API 소개(설명) 페이지에 표시되는 자료입니다.') +
`<div style="min-width:890px;max-width:900px"><div class="mb-2 flex justify-end"><button id="btn-regen-res-spec" class="px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50">스키마로 재생성</button></div>` +
'<div id="msg-editor"></div></div>'
);
root.innerHTML = subTabBar(tabs, tab, 'data-resinfo-tab') + body;
if (tab === 'desc') {
var fc = document.getElementById('form-content');
// 카드/탭바/툴바/여백 오버헤드를 제외해 폼 영역에 세로스크롤이 생기지 않는 높이로
var h = Math.max(300, (fc ? fc.clientHeight : 600) - 290);
initSpecEditor('#msg-editor', 'response', h);
}
}
// 응답 Header 카드 — 상태코드별 응답 헤더(examples.response[code].headers)
function renderResponseHeaderCard() {
const codes = Object.keys(state.data.examples.response);
const cur = state.responseStatusTab;
const curSet = state.data.examples.response[cur] || { body: '', headers: [] };
return card(
sectionTitle('응답 Header', '상태코드별 응답 헤더를 정의합니다. 추가한 상태코드는 Body/[요청/응답 샘플]과 연동됩니다.') +
`<div class="flex items-center gap-1 border-b border-slate-200 mb-3 flex-wrap">
${codes.map(code => `
<button data-ex-res-tab="${code}" class="px-3 py-1.5 text-sm rounded-t border-b-2
${cur === code ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600'}">${escapeHtml(code)}${code !== '200' ? ` <span data-del-status="${code}" class="ml-1 text-red-400 hover:text-red-600" title="상태코드 삭제">✕</span>` : ''}</button>
`).join('')}
<button id="btn-add-status" class="ml-2 px-2 py-1 text-sm rounded border border-dashed border-slate-300 text-brand-600 hover:bg-brand-50">+ 상태코드</button>
</div>` +
`<table class="w-full text-sm">
<thead class="bg-slate-50 text-xs text-slate-500 uppercase">
<tr><th class="py-2 px-2 text-left">name</th><th class="py-2 px-2 text-left w-32">type</th><th class="py-2 px-2 text-left">description</th><th class="py-2 px-2 text-left w-32">example</th><th class="w-10"></th></tr>
</thead>
<tbody>
${(curSet.headers || []).map((h, i) => `
<tr class="border-b border-slate-100">
<td class="py-2 px-2">${input(h.name, `data-resp-header="${i}.name"`)}</td>
<td class="py-2 px-2">${selectInput(h.type, ['string', 'integer', 'number', 'boolean'], `data-resp-header="${i}.type"`)}</td>
<td class="py-2 px-2">${input(h.description, `data-resp-header="${i}.description"`)}</td>
<td class="py-2 px-2">${input(h.example, `data-resp-header="${i}.example"`)}</td>
<td class="py-2 px-2 text-center"><button data-del-header="${i}" class="text-red-500 hover:text-red-700">✕</button></td>
</tr>
`).join('')}
</tbody>
</table>
<div class="mt-3 flex justify-end">
<button id="btn-add-header" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 헤더 추가</button>
</div>`
);
}
function renderStep3Section() {
// add-row/자식추가/토글/탭전환 재렌더용 — 현재 스텝 폼 재렌더(활성 서브탭 유지)
renderForm();
}
// --- Parameters 테이블 (kind: 'header' = in=header / 'param' = query 전용) ---
function renderParamsTable(kind) {
const isHeader = kind === 'header';
const entries = state.data.parameters
.map((p, i) => ({ p: p, i: i }))
.filter(e => isHeader ? e.p.in === 'header' : e.p.in === 'query');
const rows = entries.map(e => {
const p = e.p, i = e.i;
return `
<tr class="border-b border-slate-100 ${p.name.locked ? 'locked-row' : ''}">
<td class="py-2 px-2">${p.name.locked ? lockedInput(p.name.value) : input(p.name.value, `data-path="parameters.${i}.name.value"`)}</td>
<td class="py-2 px-2">${input(p.description.value, `data-path="parameters.${i}.description.value"`, { placeholder: '설명' })}</td>
<td class="py-2 px-2">${p.type.locked
? lockedInput(p.type.value)
: selectInput(p.type.value, ['string', 'integer', 'number', 'boolean'], `data-path="parameters.${i}.type.value"`)}</td>
<td class="py-2 px-2">${input(p.maxLength.value, `data-path="parameters.${i}.maxLength.value"`, { placeholder: '0', extra: 'text-xs' })}</td>
<td class="py-2 px-2 text-center">${checkbox(p.required.value, `data-path="parameters.${i}.required.value" data-type="bool"`, { locked: p.required.locked })}</td>
<td class="py-2 px-2">${input(p.example.value, `data-path="parameters.${i}.example.value"`, { placeholder: '예제값' })}</td>
<td class="py-2 px-2 text-center">
${p.name.locked ? '' : `<button data-del-param="${i}" class="text-red-500 hover:text-red-700">✕</button>`}
</td>
</tr>`;
}).join('');
return card(
sectionTitle(isHeader ? '요청 Header' : 'Parameter (Query)', '게이트웨이가 등록한 항목은 잠금됩니다. 추가 항목은 자유롭게 정의하세요.') +
`<table class="w-full text-sm">
<thead class="bg-slate-50 text-xs text-slate-500 uppercase">
<tr>
<th class="py-2 px-2 text-left w-44">이름</th>
<th class="py-2 px-2 text-left">설명</th>
<th class="py-2 px-2 text-left w-28">타입</th>
<th class="py-2 px-2 text-left w-20">길이</th>
<th class="py-2 px-2 text-center w-14">필수</th>
<th class="py-2 px-2 text-left w-40">예제값</th>
<th class="py-2 px-2 w-10"></th>
</tr>
</thead>
<tbody>${rows || `<tr><td colspan="7" class="py-6 text-center text-sm text-slate-400">등록된 항목이 없습니다</td></tr>`}</tbody>
</table>
<div class="mt-3 flex justify-end">
<button data-add-param="${isHeader ? 'header' : 'query'}" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ ${isHeader ? '헤더' : '파라미터'} 추가</button>
</div>`
);
}
// --- Body 스키마 테이블 ---
function renderBodyTable() {
return card(
sectionTitle('Request Body 스키마', `media type: ${state.data.requestBody.mediaType}. 중첩된 object/array 를 모두 지원합니다.`) +
`<div class="mb-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs text-amber-800 leading-relaxed">
<div class="font-semibold mb-1">기본 동작과 잠금</div>
<ul class="list-disc pl-4 space-y-1">
<li>여기서 설정하는 validation(타입·필수·제약 등)은 <b>실제 게이트웨이(GW) 동작에는 영향을 주지 않습니다.</b> 개발자 포털과 OpenAPI Spec 문서에만 반영되는 참고 정보입니다.</li>
<li><span class="whitespace-nowrap">🔒 표시된 필드</span>는 <b>GW 레이아웃에 등록된 필드</b>로, 삭제하거나 속성(이름·타입·필수 등)을 변경할 수 없습니다. 추가 필드는 자유롭게 정의·삭제할 수 있습니다.</li>
</ul>
</div>` +
renderSchemaTable(state.data.requestBody.schema, 'requestBody.schema') +
`<div class="mt-3 flex justify-end">
<button data-add-row="requestBody.schema" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 최상위 필드 추가</button>
</div>`
);
}
// --- Response 스키마 테이블 ---
function renderResponseTable() {
const codes = Object.keys(state.data.responses);
const cur = state.responseStatusTab;
const r = state.data.responses[cur];
const tabsHtml = codes.map(code => `
<button data-res-tab="${code}" class="px-3 py-1.5 text-sm rounded-t border-b-2 transition
${code === cur ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600 hover:text-slate-800'}">
${escapeHtml(code)}${code !== '200' ? ` <span data-del-status="${code}" class="ml-1 text-red-400 hover:text-red-600" title="상태코드 삭제">✕</span>` : ''}
</button>
`).join('');
return card(
sectionTitle('Response 스키마', '상태 코드별로 응답 스키마를 정의합니다. 추가한 상태코드는 [요청/응답 샘플]의 응답 예제와 연동됩니다.') +
`<div class="flex items-center gap-1 border-b border-slate-200 mb-3 flex-wrap">${tabsHtml}
<button id="btn-add-status" class="ml-2 px-2 py-1 text-sm rounded border border-dashed border-slate-300 text-brand-600 hover:bg-brand-50">+ 상태코드</button>
</div>` +
`<div class="mb-3">${fieldRow('설명', input(r.description.value, `data-path="responses.${cur}.description.value"`, { placeholder: '응답 설명' }))}</div>` +
renderSchemaTable(r.schema, `responses.${cur}.schema`) +
`<div class="mt-3 flex justify-end">
<button data-add-row="responses.${cur}.schema" class="px-3 py-1.5 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 필드 추가</button>
</div>`
);
}
function renderComponentsArea() {
return card(
sectionTitle('Components / 재사용 스키마', '여러 오퍼레이션에서 공유할 스키마를 정의합니다.') +
`<div class="text-sm text-slate-500 text-center py-10 border-2 border-dashed border-slate-200 rounded">
등록된 컴포넌트가 없습니다. <button class="text-brand-600 hover:underline ml-1" onclick="alert('POC: 컴포넌트 생성 UI 는 추후 확장')">+ 새 스키마 정의</button>
</div>`
);
}
// 중첩 스키마 테이블 렌더 (재귀)
function renderSchemaTable(rows, basePath) {
return `
<table class="w-full text-sm schema-table">
<thead class="bg-slate-50 text-xs text-slate-500 uppercase">
<tr>
<th class="py-2 px-2 text-left w-16">#</th>
<th class="py-2 px-2 text-center w-10">잠금</th>
<th class="py-2 px-2 text-left w-56">필드명</th>
<th class="py-2 px-2 text-left">설명</th>
<th class="py-2 px-2 text-left w-28">타입</th>
<th class="py-2 px-2 text-left w-20">길이</th>
<th class="py-2 px-2 text-center w-14">필수</th>
<th class="py-2 px-2 text-left w-40">예제값</th>
<th class="py-2 px-2 w-10"></th>
</tr>
</thead>
<tbody>
${renderSchemaRows(rows, basePath, 0, '')}
</tbody>
</table>
`;
}
function renderSchemaRows(rows, basePath, depth, seqPrefix) {
let html = '';
rows.forEach((row, idx) => {
const path = `${basePath}.${idx}`;
const seq = seqPrefix ? `${seqPrefix}-${idx + 1}` : String(idx + 1); // 계층 시퀀스 (1, 1-1, 2-1-1 …)
const isLocked = row.name.locked;
const isObject = row.type.value === 'object';
const isArray = row.type.value === 'array';
const expandedKey = `${path}.__expanded`;
const isExpanded = state[expandedKey] !== false;
html += `
<tr class="border-b border-slate-100 ${isLocked ? 'locked-row' : ''}" data-row-path="${path}">
<td class="py-2 px-2 text-xs text-slate-500 whitespace-nowrap">${seq}</td>
<td class="py-2 px-2 text-center">${isLocked ? '<span class="text-slate-400 text-xs" title="게이트웨이 동기화">🔒</span>' : ''}</td>
<td class="py-2 px-2">
<div class="flex items-center gap-1" style="padding-left:${depth * 18}px;">
${isObject || isArray
? `<button data-toggle-expand="${path}" class="w-4 h-4 text-slate-400 hover:text-slate-700">${isExpanded ? '▾' : '▸'}</button>`
: `<span class="w-4 inline-block"></span>`}
<span class="text-xs text-slate-400 mr-1">${isObject ? '{}' : isArray ? '[]' : '·'}</span>
${isLocked
? `<input type="text" value="${escapeHtml(row.name.value)}" readonly tabindex="-1"
class="flex-1 w-full px-2 py-1 text-sm rounded border border-slate-200 bg-slate-100 text-slate-700 cursor-not-allowed" title="API 게이트웨이에서 동기화" />`
: input(row.name.value, `data-path="${path}.name.value"`, { extra: 'flex-1' })}
</div>
</td>
<td class="py-2 px-2">${input(row.description.value, `data-path="${path}.description.value"`, { placeholder: '설명' })}</td>
<td class="py-2 px-2">
${row.type.locked
? `<input type="text" value="${escapeHtml(row.type.value)}" readonly tabindex="-1" class="w-full px-2 py-1 text-sm rounded border border-slate-200 bg-slate-100 text-slate-700 cursor-not-allowed" />`
: selectInput(row.type.value,
['string', 'integer', 'number', 'boolean', 'array', 'object'],
`data-path="${path}.type.value" data-rerender="step3"`)}
</td>
<td class="py-2 px-2">
${row.type.value === 'string'
? input(row.maxLength.value, `data-path="${path}.maxLength.value"`, { placeholder: '0', extra: 'text-xs' })
: '<span class="text-xs text-slate-300">—</span>'}
</td>
<td class="py-2 px-2 text-center">
${checkbox(!!row.required.value, `data-path="${path}.required.value" data-type="bool"`, { locked: row.required.locked })}
</td>
<td class="py-2 px-2">
${isObject ? '<span class="text-xs text-slate-300">— (자식 참조)</span>' :
isArray
? `<div class="flex gap-1 items-center">
<span class="text-xs text-slate-400">items:</span>
${selectInput((row.itemsType && row.itemsType.value) || 'string', ['string', 'integer', 'number', 'boolean'], `data-path="${path}.itemsType.value"`, { extra: 'text-xs' })}
</div>`
: input(row.example.value, `data-path="${path}.example.value"`, { placeholder: '예제값' })}
</td>
<td class="py-2 px-2 text-center">
${isLocked
? ''
: `<button data-del-row="${path}" class="text-red-500 hover:text-red-700">✕</button>`}
</td>
</tr>
`;
// children
if (isObject && isExpanded) {
if (row.children && row.children.length) {
html += renderSchemaRows(row.children, `${path}.children`, depth + 1, seq);
}
html += `
<tr class="bg-slate-50/50">
<td colspan="9" class="py-1.5 px-2" style="padding-left:${(depth + 1) * 18 + 24}px;">
<button data-add-row="${path}.children" class="text-xs text-brand-600 hover:underline">+ 자식 필드 추가</button>
</td>
</tr>
`;
}
});
return html;
}
// ============================================================
// 9. 인증 / 보안 (기본정보 Step 에 병합된 섹션)
// ============================================================
function renderAuthSection() {
var schemes = state.data.securitySchemes || [];
return card(
sectionTitle('인증 / 보안', '게이트웨이(GW)에 설정된 인증만 표시됩니다. (읽기 전용)') +
(schemes.length
? schemes.map((s, i) => renderSecuritySchemeCard(s, i)).join('')
: '<div class="text-sm text-slate-500 py-2">인증 없음</div>')
);
}
function renderSecuritySchemeCard(s, idx) {
const typeBadge = {
apiKey: 'bg-emerald-100 text-emerald-700',
http: 'bg-blue-100 text-blue-700',
oauth2: 'bg-purple-100 text-purple-700',
openIdConnect: 'bg-slate-100 text-slate-700'
}[s.type] || 'bg-slate-100 text-slate-700';
let body = '';
if (s.type === 'apiKey') {
body = `
<div class="grid grid-cols-2 gap-3 text-sm">
<div><label class="text-xs text-slate-500 block mb-1">name</label>${lockedInput(s.name ? s.name.value : '')}</div>
<div><label class="text-xs text-slate-500 block mb-1">in</label>${lockedInput(s.in ? s.in.value : 'header')}</div>
</div>
`;
} else if (s.type === 'http') {
body = `
<div class="grid grid-cols-2 gap-3 text-sm">
<div><label class="text-xs text-slate-500 block mb-1">scheme</label>${lockedInput(s.scheme ? s.scheme.value : '')}</div>
<div><label class="text-xs text-slate-500 block mb-1">bearerFormat</label>${lockedInput((s.bearerFormat && s.bearerFormat.value) || '')}</div>
</div>
`;
} else if (s.type === 'oauth2') {
body = `<div class="text-xs text-slate-500">OAuth2</div>`;
} else if (s.type === 'openIdConnect') {
body = `<div><label class="text-xs text-slate-500 block mb-1">openIdConnectUrl</label>${lockedInput(s.openIdConnectUrl ? s.openIdConnectUrl.value : '')}</div>`;
}
return `
<div class="border border-slate-200 rounded-lg p-3 mb-3 locked-card">
<div class="flex items-center gap-2 mb-3">
<code class="text-sm font-semibold text-slate-800">${escapeHtml(s.key)}</code>
<span class="px-2 py-0.5 text-[10px] uppercase rounded ${typeBadge}">${escapeHtml(s.type)}</span>
<span class="text-xs text-slate-400 ml-1">🔒 GW</span>
</div>
${body}
${(s.description && s.description.value) ? `<div class="mt-3 text-xs text-slate-500">${escapeHtml(s.description.value)}</div>` : ''}
</div>
`;
}
// ============================================================
// 10. Step 4 — 요청/응답 샘플 (하위 탭: 요청=편집, 응답=읽기전용(응답 정보에서 파생))
// ============================================================
// 응답 예제 본문을 응답 정보(스키마·예제값)에서 파생해 동기화 — 응답 샘플은 항상 응답 정보 세팅을 따른다.
function syncResponseExamplesFromSchema() {
var d = state.data;
Object.keys(d.responses || {}).forEach(function (code) {
var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] });
set.body = JSON.stringify(_sampleObj((d.responses[code] || {}).schema || []), null, 2);
});
}
function renderStep5(root) {
const tab = state.sampleTab || 'request';
const tabs = [{ key: 'request', label: '요청' }, { key: 'response', label: '응답' }];
const taCls = 'w-full px-3 py-2 text-xs font-mono rounded border border-slate-300 focus:outline-none focus:ring-2 focus:ring-brand-500';
let body;
if (tab === 'request') {
const _mt = (state.data.requestBody && state.data.requestBody.mediaType) || 'application/json';
const req = (state.data.examples.request[_mt] || { default: '' }).default || '';
body = card(
sectionTitle('요청 본문 예제 (application/json)') +
`<div class="mb-2 flex justify-end"><button id="btn-regen-req-ex" class="px-2 py-1 text-xs rounded border border-slate-300 hover:bg-slate-50">스키마로 재생성</button></div>` +
`<textarea data-example-target="request" rows="10" class="${taCls}">${escapeHtml(req)}</textarea>` +
`<div class="mt-2 text-xs text-slate-400">스키마와 자동 매핑되어 Swagger UI 의 예제 영역에 노출됩니다.</div>`
);
} else {
syncResponseExamplesFromSchema(); // 응답 정보 세팅 → 예제 파생(읽기 전용)
const codes = Object.keys(state.data.examples.response);
const cur = state.responseStatusTab;
const curSet = state.data.examples.response[cur] || { body: '', headers: [] };
body = card(
sectionTitle('응답 예제 (읽기 전용)', '응답 정보(스키마·예제값)에서 자동 생성됩니다. 수정은 [응답 정보] 탭에서 하세요.') +
`<div class="flex items-center gap-1 border-b border-slate-200 mb-3 flex-wrap">
${codes.map(code => `
<button data-ex-res-tab="${code}" class="px-3 py-1.5 text-sm rounded-t border-b-2
${cur === code ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600'}">${escapeHtml(code)}</button>
`).join('')}
</div>` +
`<label class="text-xs text-slate-500 block mb-1">응답 본문 (${escapeHtml(cur)})</label>` +
`<textarea data-example-target="response" rows="12" readonly class="${taCls} bg-slate-50">${escapeHtml(curSet.body || '')}</textarea>` +
`<div class="mt-2 text-xs text-slate-400">응답 헤더는 [응답 정보 > Header] 에서 관리합니다.</div>`
);
}
root.innerHTML = subTabBar(tabs, tab, 'data-sample-tab') + body;
initExampleEditors(tab); // 요청=편집 CodeMirror, 응답=읽기전용 CodeMirror
}
// (구 요청/응답(웹페이지) 스텝은 요청/응답 정보의 'API 설명(HTML)' 서브 탭으로 흡수됨)
// 스키마 → 설명 자료 HTML 표(필드명/타입/필수/길이/설명). 현재 편집 중 스키마(추가 필드 포함) 기준.
function schemaToSpecHtml(rows) {
var out = '<table style="border-collapse:collapse;width:100%" border="1"><thead><tr>'
+ '<th style="padding:4px 8px;background:#f1f5f9;text-align:left">필드명</th>'
+ '<th style="padding:4px 8px;background:#f1f5f9;text-align:center">타입</th>'
+ '<th style="padding:4px 8px;background:#f1f5f9;text-align:center">필수</th>'
+ '<th style="padding:4px 8px;background:#f1f5f9;text-align:center">길이</th>'
+ '<th style="padding:4px 8px;background:#f1f5f9;text-align:left">설명</th></tr></thead><tbody>';
function walk(list, depth) {
(list || []).forEach(function (r) {
if (!r || !r.name) return;
var pad = new Array(depth + 1).join('&nbsp;&nbsp;&nbsp;&nbsp;');
var req = 'Y'; // 설명 자료 HTML: 필수 여부 기본값 = 필수(Y)
var len = (r.maxLength && r.maxLength.value) || '';
// 필드명·설명 = 좌측 정렬, 나머지(타입·필수·길이) = 가운데 정렬
out += '<tr>'
+ '<td style="padding:4px 8px">' + pad + escapeHtml(r.name.value || '') + '</td>'
+ '<td style="padding:4px 8px;text-align:center">' + escapeHtml(r.type.value || '') + '</td>'
+ '<td style="padding:4px 8px;text-align:center">' + req + '</td>'
+ '<td style="padding:4px 8px;text-align:center">' + escapeHtml(String(len)) + '</td>'
+ '<td style="padding:4px 8px">' + escapeHtml((r.description && r.description.value) || '') + '</td></tr>';
if (r.children && r.children.length) walk(r.children, depth + 1);
});
}
walk(rows, 0);
out += '</tbody></table>';
return out;
}
function regenReqSpec() {
var d = state.data;
d.msgSpec.request = schemaToSpecHtml(d.requestBody ? d.requestBody.schema : []);
renderForm();
toast('요청 설명 자료를 스키마 기준으로 재생성했습니다');
}
function regenResSpec() {
var d = state.data;
var r = (d.responses && (d.responses['200'] || d.responses[Object.keys(d.responses)[0]])) || {};
d.msgSpec.response = schemaToSpecHtml(r.schema || []);
renderForm();
toast('응답 설명 자료를 스키마 기준으로 재생성했습니다');
}
// 개발자포탈과 동일한 Summernote 툴바 + 편집영역(.editor-content) 스타일
var DJB_SN_TOOLBAR = [
['style', ['style']],
['font', ['bold', 'underline', 'clear']],
['color', ['color']],
['para', ['ul', 'ol', 'paragraph']],
['table', ['table']],
['insert', ['link', 'picture']],
['view', ['fullscreen', 'codeview', 'help']]
];
function addEditorContentClass() { if (window.jQuery) window.jQuery('.note-editable').addClass('editor-content'); }
// 설명 자료 = Summernote 리치 에디터. state.data.msgSpec[key] 에 HTML 저장.
function initSpecEditor(sel, key, height) {
if (!(window.jQuery && window.jQuery.fn && window.jQuery.fn.summernote)) return;
var $e = window.jQuery(sel);
if (!$e.length) return;
try { $e.summernote('destroy'); } catch (e) {}
var koOk = window.jQuery.summernote && window.jQuery.summernote.lang && window.jQuery.summernote.lang['ko-KR'];
$e.summernote({
height: height || 300,
lang: koOk ? 'ko-KR' : 'en-US',
placeholder: '여기에 내용을 입력하세요.',
toolbar: DJB_SN_TOOLBAR,
callbacks: {
onInit: addEditorContentClass,
onChange: function (contents) { state.data.msgSpec[key] = contents; }
}
});
$e.summernote('code', state.data.msgSpec[key] || '');
addEditorContentClass();
}
// 요청/응답 본문 예제 textarea 를 CodeMirror(JSON) 에디터로 승격. renderStep5 마다 재생성(폼 재렌더 시 wipe→재초기화).
// 요청 = 편집 가능, 응답 = 읽기 전용(응답 정보에서 파생).
function initExampleEditors() {
if (!window.CodeMirror) return;
var cmOpt = { mode: { name: 'javascript', json: true }, lineNumbers: true, lineWrapping: true, viewportMargin: Infinity, tabSize: 2 };
var reqTa = $('[data-example-target="request"]');
if (reqTa) {
var cmReq = CodeMirror.fromTextArea(reqTa, cmOpt);
cmReq.setSize('100%', 300);
cmReq.on('change', function (c) {
var mt = (state.data.requestBody && state.data.requestBody.mediaType) || 'application/json';
state.data.examples.request[mt] = state.data.examples.request[mt] || { default: '' };
state.data.examples.request[mt].default = c.getValue();
schedulePreviewSync();
});
}
var resTa = $('[data-example-target="response"]');
if (resTa) {
var cmRes = CodeMirror.fromTextArea(resTa, Object.assign({}, cmOpt, { readOnly: true }));
cmRes.setSize('100%', 300);
}
}
// 응답유형별 실제 호출 주소. 경로(op.path.value)에 어댑터경로가 이미 포함, 호스트만 응답유형별로 앞에 붙임.
function resolvedCallUrl() {
var d = state.data;
var rt = (d.portal && d.portal.responseType) || 'sample';
var host;
if (rt === 'gw') host = (window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310';
else if (rt === 'mock') host = (d.portal && d.portal.mockUrl) || '';
else host = '';
var path = (d.operation && d.operation.path && d.operation.path.value) || '';
if (!host) return path || '(주소 없음)';
return host.replace(/\/+$/, '') + (path.charAt(0) === '/' ? path : '/' + path);
}
// ============================================================
// 11. Step 6 — 문서 옵션 + 풀 미리보기
// ============================================================
function renderStep6(root) {
const p = state.data.portal || {};
const roles = p.roles || {};
const rt = p.responseType || 'sample';
const showOrg = !!(roles.ROLE_CORP_USER || roles.ROLE_CORP_MANAGER); // 법인사용자/관리자 선택 시에만 공개법인 노출
const orgChips = (p.orgs || []).map((o, i) =>
`<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-brand-50 border border-brand-200 text-brand-700 mr-1">${escapeHtml(o.orgName || o.id)}<b data-del-org="${i}" class="cursor-pointer text-brand-400 hover:text-brand-700">✕</b></span>`
).join('');
const groupChips = (p.apiGroups || []).map((g, i) =>
`<span class="tag-chip inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-brand-50 border border-brand-200 text-brand-700 mr-1">${escapeHtml(g.groupName || g.id)}<b data-del-group="${i}" class="cursor-pointer text-brand-400 hover:text-brand-700">✕</b></span>`
).join('');
root.innerHTML =
card(
sectionTitle('포탈 게시 정보', '개발자포탈 공개 설정. 기존 API 스펙 화면의 값을 불러와 적용합니다.') +
fieldRow('API 그룹',
`<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 flex-wrap">
<span id="group-chips">${groupChips || '<span class="text-xs text-slate-400">선택된 그룹 없음</span>'}</span>
<button type="button" id="btn-select-group" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
</div>
<div class="text-xs text-slate-400">API 그룹은 apiGroupMan(API 그룹 관리)에서도 관리됩니다.</div>
</div>`) +
fieldRow('공개 여부', `<label class="inline-flex items-center gap-2 text-sm">${checkbox(p.displayYn === 'Y', 'data-portal-yn')} 공개</label>`) +
fieldRow('공개 권한',
`<div class="flex flex-col gap-1">
<div class="flex gap-4">
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_USER, 'data-role="ROLE_USER"')} 개인사용자</label>
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_USER, 'data-role="ROLE_CORP_USER"')} 법인사용자</label>
<label class="inline-flex items-center gap-1 text-sm">${checkbox(!!roles.ROLE_CORP_MANAGER, 'data-role="ROLE_CORP_MANAGER"')} 법인관리자</label>
</div>
<div class="text-xs text-slate-400">전부 선택 시 로그인 사용자 전체에게 공개됩니다.</div>
</div>`) +
(showOrg ? fieldRow('공개 법인',
`<div class="flex flex-col gap-1">
<div class="flex items-center gap-2 flex-wrap">
<span id="org-chips">${orgChips || '<span class="text-xs text-slate-400">선택된 법인 없음</span>'}</span>
<button type="button" id="btn-select-org" class="px-2 py-1 text-sm rounded border border-slate-300 hover:bg-slate-50">+ 선택</button>
</div>
<div class="text-xs text-slate-400">특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.</div>
</div>`) : '') +
fieldRow('응답 유형',
'<div>' +
['sample', 'mock', 'gw'].map(v =>
`<label class="inline-flex items-center gap-1 text-sm mr-3"><input type="radio" name="portal-rt" data-portal-rt="${v}" ${rt === v ? 'checked' : ''}> ${v === 'gw' ? 'GW' : v}</label>`
).join('') +
`<div class="mt-2 text-xs text-slate-500">실제 호출 주소: <code id="resolved-call-url" class="font-mono text-slate-700 break-all">${escapeHtml(resolvedCallUrl())}</code></div>` +
(rt === 'gw' ? `<div class="mt-1 text-xs text-slate-400">GW 주소는 포탈 설정(PTL_PROPERTY, 그룹 Portal)의 <code class="font-mono">djb.gateway.base-url</code> 값을 사용합니다.</div>` : '') +
'</div>') +
(rt === 'mock' ? fieldRow('Mock Server URL', input(p.mockUrl || '', 'data-portal="mockUrl"', { placeholder: 'https://mock.example.com' }), { hint: 'Mock 응답을 제공할 서버의 기본 URL. 응답 유형이 Mock 일 때 개발자포탈이 이 주소로 요청을 전달하고, Swagger 스펙의 서버 주소로도 사용됩니다.' }) : '')
) +
card(
sectionTitle('설명 (API 소개 페이지)', 'API 소개 페이지에만 표시됩니다. OpenAPI 스펙 파일에는 포함되지 않습니다.') +
'<div id="info-desc-editor">' + (state.data.info.description.value || '') + '</div>'
) +
card(
sectionTitle('내보내기', 'OpenAPI 스펙을 파일로 저장합니다.') +
`<div class="flex gap-2">
<button data-export="yaml" class="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700">YAML 다운로드</button>
<button data-export="json" class="px-4 py-2 text-sm rounded border border-brand-300 text-brand-700 hover:bg-brand-50">JSON 다운로드</button>
</div>`
);
initInfoDescEditor(); // 설명 리치 에디터 (API 소개 페이지 전용)
}
// ============================================================
// 12. 미리보기 (Swagger / Monaco)
// ============================================================
let monacoEditor = null;
let monacoReady = false;
let swaggerInstance = null;
// admin 오프라인 환경: Monaco(AMD/CDN) 대신 CodeMirror(YAML, script/JSP 에서 로드) 사용.
function initMonaco() {
return new Promise(resolve => {
monacoEditor = CodeMirror($('#preview-monaco'), {
value: '', mode: 'yaml', readOnly: true, lineNumbers: true, lineWrapping: false
});
monacoEditor.setSize('100%', '100%');
monacoReady = true;
resolve();
});
}
function mountSwagger() {
const spec = buildOpenApiSpec();
swaggerInstance = SwaggerUIBundle({
spec: spec,
domNode: $('#preview-swagger'),
deepLinking: false,
presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
layout: state.data.docOptions.layout,
defaultModelsExpandDepth: state.data.docOptions.defaultExpandDepth,
defaultModelExpandDepth: state.data.docOptions.defaultExpandDepth,
docExpansion: state.data.docOptions.defaultExpandDepth === -1 ? 'full' : (state.data.docOptions.defaultExpandDepth === 0 ? 'none' : 'list'),
tryItOutEnabled: !!state.data.docOptions.tryItOut,
filter: !!state.data.docOptions.tagFilter.length
});
}
function reMountSwagger() { mountSwagger(); }
function updateMonacoFromState() {
if (!monacoReady) return;
const spec = buildOpenApiSpec();
const yaml = jsyaml.dump(spec, { noRefs: true, lineWidth: 120 });
if (monacoEditor.getValue() !== yaml) monacoEditor.setValue(yaml);
}
function setPreviewTab(tab) {
state.previewTab = tab;
$$('.preview-tab').forEach(b => {
const active = b.dataset.ptab === tab;
b.classList.toggle('border-brand-600', active);
b.classList.toggle('text-brand-700', active);
b.classList.toggle('font-medium', active);
b.classList.toggle('border-transparent', !active);
b.classList.toggle('text-slate-600', !active);
});
$('#preview-swagger').classList.toggle('hidden', tab !== 'swagger');
$('#preview-monaco').classList.toggle('hidden', tab === 'swagger');
$('#yaml-edit-bar').classList.toggle('hidden', tab !== 'yaml-edit');
if (monacoReady && tab !== 'swagger') {
monacoEditor.setOption('readOnly', tab !== 'yaml-edit');
updateMonacoFromState();
setTimeout(() => monacoEditor.refresh(), 50);
}
if (tab === 'swagger') reMountSwagger();
applyEditLock(tab === 'yaml-edit');
}
// 스펙 편집 모드: 폼 입력 30% 알파 레이어 차단 + 탭/단계 전환 잠금
function applyEditLock(on) {
// 입력 UI(form-content) + 상단 탭(stepper) 를 30% 알파로 낮추고 클릭 차단. (오버레이/position 변경 없이 → 레이아웃 안전)
var fc = $('#form-content');
var stp = $('#stepper');
var bp = $('#btn-prev'), bn = $('#btn-next'), bh = $('#btn-toggle-preview');
if (on) {
if (fc) { fc.style.opacity = '0.3'; fc.style.pointerEvents = 'none'; }
if (stp) { stp.style.opacity = '0.3'; stp.style.pointerEvents = 'none'; }
if (bp) bp.disabled = true;
if (bn) bn.disabled = true;
if (bh) { bh.disabled = true; bh.style.opacity = '0.5'; bh.style.pointerEvents = 'none'; }
$$('.preview-tab').forEach(function (b) { if (b.dataset.ptab !== 'yaml-edit') { b.style.pointerEvents = 'none'; b.style.opacity = '0.5'; } });
} else {
if (fc) { fc.style.opacity = ''; fc.style.pointerEvents = ''; }
if (stp) { stp.style.opacity = ''; stp.style.pointerEvents = ''; }
if (bp) bp.disabled = (state.step === 1);
if (bn) bn.disabled = false;
if (bh) { bh.disabled = false; bh.style.opacity = ''; bh.style.pointerEvents = ''; }
$$('.preview-tab').forEach(function (b) { b.style.pointerEvents = ''; b.style.opacity = ''; });
}
}
const schedulePreviewSync = debounce(() => {
if (!state.showPreview) return;
if (state.previewTab === 'swagger') reMountSwagger();
else if (state.previewTab !== 'yaml-edit') updateMonacoFromState();
}, 300);
// ============================================================
// 13. Form 데이터 변경 이벤트 (delegated)
// ============================================================
function setIn(obj, path, value) {
const parts = path.split('.');
let cur = obj;
for (let i = 0; i < parts.length - 1; i++) {
const k = isNaN(parts[i]) ? parts[i] : Number(parts[i]);
if (cur[k] == null) cur[k] = isNaN(parts[i + 1]) ? {} : [];
cur = cur[k];
}
cur[isNaN(parts[parts.length - 1]) ? parts[parts.length - 1] : Number(parts[parts.length - 1])] = value;
}
function getIn(obj, path) {
return path.split('.').reduce((c, k) => (c == null ? c : c[isNaN(k) ? k : Number(k)]), obj);
}
function bindFormEvents() {
const form = $('#form-content');
form.addEventListener('input', e => {
const t = e.target;
const p = t.dataset.path;
if (!p) return;
let v = t.type === 'checkbox' ? t.checked : t.value;
if (t.dataset.type === 'bool') v = !!v;
if (t.dataset.type === 'int') v = parseInt(v, 10);
// 'data.xxx' prefix 는 state 직접경로, 아니면 state.data 하위
if (p.startsWith('data.')) setIn(state, p, v);
else setIn(state.data, p, v);
schedulePreviewSync();
});
form.addEventListener('change', e => {
const t = e.target;
if (t.dataset.rerender === 'step3') { renderStep3Section(); return; }
if (t.dataset.rerender === 'preview') { reMountSwagger(); return; }
});
form.addEventListener('click', e => {
const target = e.target;
// Step 3: 섹션 사이드 네비
const secBtn = target.closest('[data-sec]');
if (secBtn) {
state.step3Section = secBtn.dataset.sec;
renderStep3();
return;
}
// 상태코드 삭제 (탭 안의 ✕) — 탭 전환(data-res-tab / data-ex-res-tab)보다 먼저 처리
const delStatus = target.closest('[data-del-status]');
if (delStatus) {
const dcode = delStatus.dataset.delStatus;
if (dcode !== '200') {
delete state.data.responses[dcode];
delete state.data.examples.response[dcode];
state.responseStatusTab = state.data.examples.response['200'] ? '200' : (Object.keys(state.data.examples.response)[0] || '200');
renderForm();
schedulePreviewSync();
}
return;
}
// Response status tab
const resTab = target.closest('[data-res-tab]');
if (resTab) {
state.responseStatusTab = resTab.dataset.resTab;
renderStep3Section();
return;
}
// 상태코드 추가
if (target.id === 'btn-add-status') {
const code = (window.prompt('추가할 HTTP 상태코드 (100~599)', '400') || '').trim();
if (!code) return;
if (!/^[1-5][0-9]{2}$/.test(code)) { toast('유효한 상태코드가 아닙니다', 'warn'); return; }
if (state.data.examples.response[code]) { state.responseStatusTab = code; renderForm(); return; }
state.data.responses[code] = { description: { value: '', locked: false }, schema: [] };
state.data.examples.response[code] = { body: '{}', headers: [] };
state.responseStatusTab = code;
renderForm();
schedulePreviewSync();
return;
}
// 예제 응답 탭
const exResTab = target.closest('[data-ex-res-tab]');
if (exResTab) {
state.responseStatusTab = exResTab.dataset.exResTab;
renderForm();
return;
}
// 요청/응답 샘플 서브 탭 전환
const sampleTabBtn = target.closest('[data-sample-tab]');
if (sampleTabBtn) { state.sampleTab = sampleTabBtn.dataset.sampleTab; renderForm(); return; }
// 요청/응답 정보 서브 탭 전환
const reqInfoTabBtn = target.closest('[data-reqinfo-tab]');
if (reqInfoTabBtn) { state.reqInfoTab = reqInfoTabBtn.dataset.reqinfoTab; renderForm(); return; }
const resInfoTabBtn = target.closest('[data-resinfo-tab]');
if (resInfoTabBtn) { state.resInfoTab = resInfoTabBtn.dataset.resinfoTab; renderForm(); return; }
// 행 추가
const addRow = target.closest('[data-add-row]');
if (addRow) {
const targetPath = addRow.dataset.addRow;
const arr = getIn(state.data, targetPath) || [];
const parent = getIn(state.data, targetPath.replace(/\.children$/, ''));
if (targetPath.endsWith('.children') && parent && !parent.children) parent.children = [];
const target2 = targetPath.endsWith('.children') ? parent.children : arr;
target2.push(newSchemaRow());
if (targetPath.endsWith('.children')) state[`${targetPath.replace(/\.children$/, '')}.__expanded`] = true;
renderStep3Section();
schedulePreviewSync();
return;
}
// 행 삭제
const delRow = target.closest('[data-del-row]');
if (delRow) {
const p = delRow.dataset.delRow;
const parts = p.split('.');
const idx = Number(parts.pop());
const parent = getIn(state.data, parts.join('.'));
parent.splice(idx, 1);
renderStep3Section();
schedulePreviewSync();
return;
}
// 펼침 토글
const expBtn = target.closest('[data-toggle-expand]');
if (expBtn) {
const key = `${expBtn.dataset.toggleExpand}.__expanded`;
state[key] = state[key] === false ? true : false;
renderStep3Section();
return;
}
// 제약 더보기 토글
const conBtn = target.closest('[data-toggle-constraints]');
if (conBtn) {
const key = `${conBtn.dataset.toggleConstraints}.__constraints`;
state[key] = !state[key];
renderStep3Section();
return;
}
// 파라미터 삭제
const delParam = target.closest('[data-del-param]');
if (delParam) {
state.data.parameters.splice(Number(delParam.dataset.delParam), 1);
renderStep3Section();
schedulePreviewSync();
return;
}
const addParam = target.closest('[data-add-param]');
if (addParam) {
var np = newParamRow();
np.in = addParam.dataset.addParam || 'query';
state.data.parameters.push(np);
renderStep3Section();
schedulePreviewSync();
return;
}
// 보안 스킴 삭제 / 추가
const delSec = target.closest('[data-del-sec]');
if (delSec) {
state.data.securitySchemes.splice(Number(delSec.dataset.delSec), 1);
renderStep1($('#form-content'));
schedulePreviewSync();
return;
}
if (target.id === 'btn-add-sec') { openSecModal(); return; }
// 응답 헤더 추가 / 삭제 (현재 상태코드 세트)
if (target.id === 'btn-add-header') {
const curH = state.data.examples.response[state.responseStatusTab];
if (curH) { curH.headers = curH.headers || []; curH.headers.push({ name: '', type: 'string', description: '', example: '' }); }
renderForm();
schedulePreviewSync();
return;
}
const delHeader = target.closest('[data-del-header]');
if (delHeader) {
const curD = state.data.examples.response[state.responseStatusTab];
if (curD && curD.headers) curD.headers.splice(Number(delHeader.dataset.delHeader), 1);
renderForm();
schedulePreviewSync();
return;
}
// 예제 스키마 기준 재생성
if (target.id === 'btn-regen-req-ex') { regenReqExample(); return; }
if (target.id === 'btn-regen-res-ex') { regenResExample(); return; }
// 설명 자료 파라미터/스키마 기준 재생성
if (target.id === 'btn-regen-req-spec') { regenReqSpec(); return; }
if (target.id === 'btn-regen-res-spec') { regenResSpec(); return; }
// 공개 법인 선택/삭제
if (target.id === 'btn-select-org') { openOrgPopup(); return; }
const delOrg = target.closest('[data-del-org]');
if (delOrg) {
state.data.portal.orgs.splice(Number(delOrg.dataset.delOrg), 1);
state.data.portal.displayOrg = state.data.portal.orgs.map(function (o) { return o.id; }).join(',');
renderForm();
return;
}
// API 그룹 선택/삭제
if (target.id === 'btn-select-group') { openGroupPopup(); return; }
const delGroup = target.closest('[data-del-group]');
if (delGroup) {
state.data.portal.apiGroups.splice(Number(delGroup.dataset.delGroup), 1);
renderForm();
return;
}
// Export
const exportBtn = target.closest('[data-export]');
if (exportBtn) {
downloadSpec(exportBtn.dataset.export);
return;
}
});
// 오퍼레이션 태그 토글
form.addEventListener('change', e => {
const ot = e.target.closest('[data-op-tag]');
if (ot) {
const name = ot.dataset.opTag;
const arr = state.data.operation.tags.value;
if (ot.checked && !arr.includes(name)) arr.push(name);
if (!ot.checked) arr.splice(arr.indexOf(name), 1);
schedulePreviewSync();
}
// 전역 보안
const gs = e.target.closest('[data-global-sec]');
if (gs) {
const k = gs.dataset.globalSec;
const arr = state.data.globalSecurity;
if (gs.checked && !arr.includes(k)) arr.push(k);
if (!gs.checked) arr.splice(arr.indexOf(k), 1);
schedulePreviewSync();
}
// 태그 필터
const tf = e.target.closest('[data-tag-filter]');
if (tf) {
const k = tf.dataset.tagFilter;
const arr = state.data.docOptions.tagFilter;
if (tf.checked && !arr.includes(k)) arr.push(k);
if (!tf.checked) arr.splice(arr.indexOf(k), 1);
reMountSwagger();
}
// 포탈 게시 정보 (공개여부/권한/응답유형)
if (e.target.closest('[data-portal-yn]')) { state.data.portal.displayYn = e.target.checked ? 'Y' : 'N'; }
const rl = e.target.closest('[data-role]');
if (rl) { state.data.portal.roles[rl.dataset.role] = e.target.checked; renderForm(); } // 공개법인 노출 토글
const prt = e.target.closest('[data-portal-rt]');
if (prt && e.target.checked) { state.data.portal.responseType = prt.dataset.portalRt; renderForm(); reMountSwagger(); } // Mock URL 노출 토글 + 서버 갱신
});
// 예제 textarea
form.addEventListener('input', e => {
const t = e.target;
if (t.dataset.exampleTarget === 'request') {
var _mt = (state.data.requestBody && state.data.requestBody.mediaType) || 'application/json';
state.data.examples.request[_mt] = state.data.examples.request[_mt] || { default: '' };
state.data.examples.request[_mt].default = t.value;
schedulePreviewSync();
}
if (t.dataset.exampleTarget === 'response') {
const curB = state.data.examples.response[state.responseStatusTab];
if (curB) { curB.body = t.value; schedulePreviewSync(); }
}
// 응답 헤더 인라인 편집 (현재 상태코드 세트)
if (t.dataset.respHeader) {
const [i, field] = t.dataset.respHeader.split('.');
const curSet = state.data.examples.response[state.responseStatusTab];
if (curSet && curSet.headers && curSet.headers[Number(i)]) { curSet.headers[Number(i)][field] = t.value; schedulePreviewSync(); }
}
// 포탈 게시 정보 (공개법인 / Mock URL)
if (t.dataset.portal === 'displayOrg') { state.data.portal.displayOrg = t.value; }
if (t.dataset.portal === 'mockUrl') { state.data.portal.mockUrl = t.value; var _u = $('#resolved-call-url'); if (_u) _u.textContent = resolvedCallUrl(); }
});
}
function newSchemaRow() {
return {
name: { value: '', locked: false },
type: { value: 'string', locked: false },
required: { value: false, locked: false },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '', locked: false },
children: null
};
}
function newParamRow() {
return {
in: 'query',
name: { value: '', locked: false },
type: { value: 'string', locked: false },
required: { value: false, locked: false },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '', locked: false }
};
}
// ============================================================
// 14. 보안 스킴 추가 모달
// ============================================================
function openSecModal() {
const modal = $('#sec-modal');
let chosenType = 'apiKey';
const renderBody = () => {
$('#sec-modal-body').innerHTML = `
<div class="mb-4">
<label class="block text-xs text-slate-500 mb-2">키 이름 (components 에 등록될 식별자)</label>
<input id="sec-key" placeholder="MyAuth" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
</div>
<div class="mb-4">
<label class="block text-xs text-slate-500 mb-2">타입</label>
<div class="grid grid-cols-4 gap-2">
${['apiKey', 'http', 'oauth2', 'openIdConnect'].map(t => `
<button data-sec-type="${t}" class="sec-type-btn px-3 py-2 text-xs rounded border ${chosenType === t ? 'bg-brand-50 border-brand-400 text-brand-700' : 'bg-white border-slate-300 text-slate-600'}">${escapeHtml(t)}</button>
`).join('')}
</div>
</div>
<div id="sec-detail"></div>
`;
const detail = $('#sec-detail');
if (chosenType === 'apiKey') {
detail.innerHTML = `
<div class="grid grid-cols-2 gap-3">
<div><label class="text-xs text-slate-500 block mb-1">name</label><input id="sec-name" placeholder="X-API-Key" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" /></div>
<div><label class="text-xs text-slate-500 block mb-1">in</label>
<select id="sec-in" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded">
<option value="header">header</option><option value="query">query</option><option value="cookie">cookie</option>
</select>
</div>
</div>`;
} else if (chosenType === 'http') {
detail.innerHTML = `
<div class="grid grid-cols-2 gap-3">
<div><label class="text-xs text-slate-500 block mb-1">scheme</label>
<select id="sec-scheme" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded">
<option value="bearer">bearer</option><option value="basic">basic</option><option value="digest">digest</option>
</select>
</div>
<div><label class="text-xs text-slate-500 block mb-1">bearerFormat</label>
<input id="sec-bearer" placeholder="JWT" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
</div>
</div>`;
} else if (chosenType === 'oauth2') {
detail.innerHTML = `
<div class="text-xs text-slate-500 mb-2">Flow 타입을 선택하세요 (POC 에서는 최소 메타만 저장)</div>
<select id="sec-flow" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded mb-3">
<option value="implicit">implicit</option>
<option value="password">password</option>
<option value="clientCredentials">clientCredentials</option>
<option value="authorizationCode">authorizationCode</option>
</select>
<label class="text-xs text-slate-500 block mb-1">authorizationUrl</label>
<input id="sec-authUrl" placeholder="https://auth.example.com/oauth/authorize" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded mb-2" />
<label class="text-xs text-slate-500 block mb-1">tokenUrl</label>
<input id="sec-tokenUrl" placeholder="https://auth.example.com/oauth/token" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
`;
} else {
detail.innerHTML = `
<label class="text-xs text-slate-500 block mb-1">openIdConnectUrl</label>
<input id="sec-oidc" placeholder="https://example.com/.well-known/openid-configuration" class="w-full px-3 py-1.5 text-sm border border-slate-300 rounded" />
`;
}
};
renderBody();
modal.classList.remove('hidden');
modal.onclick = (e) => {
if (e.target.dataset.secClose !== undefined || e.target.closest('[data-sec-close]')) {
modal.classList.add('hidden');
return;
}
const tb = e.target.closest('[data-sec-type]');
if (tb) { chosenType = tb.dataset.secType; renderBody(); }
};
$('#btn-sec-save').onclick = () => {
const key = $('#sec-key').value.trim();
if (!key) return toast('키 이름을 입력하세요', 'warn');
if (state.data.securitySchemes.some(s => s.key === key)) return toast('이미 존재하는 키입니다', 'error');
const newS = { key, type: chosenType, locked: false, description: { value: '', locked: false } };
if (chosenType === 'apiKey') { newS.name = { value: $('#sec-name').value, locked: false }; newS.in = { value: $('#sec-in').value, locked: false }; }
if (chosenType === 'http') { newS.scheme = { value: $('#sec-scheme').value, locked: false }; newS.bearerFormat = { value: $('#sec-bearer').value, locked: false }; }
if (chosenType === 'oauth2') {
const flow = $('#sec-flow').value;
newS.flows = { [flow]: { authorizationUrl: $('#sec-authUrl') ? $('#sec-authUrl').value : '', tokenUrl: $('#sec-tokenUrl') ? $('#sec-tokenUrl').value : '', scopes: {} } };
}
if (chosenType === 'openIdConnect') newS.openIdConnectUrl = { value: $('#sec-oidc').value, locked: false };
state.data.securitySchemes.push(newS);
modal.classList.add('hidden');
renderStep1($('#form-content'));
schedulePreviewSync();
toast(`보안 스킴 "${key}" 추가 완료`);
};
}
// ============================================================
// 15. 미리보기 패널 토글 / 레이아웃
// ============================================================
function setPreviewVisibility(show) {
state.showPreview = show;
$('#preview-panel').style.display = show ? '' : 'none';
$('#form-panel').style.width = show ? '960px' : '1600px';
$('#btn-toggle-preview-from-form').classList.toggle('hidden', show);
if (show && monacoReady) setTimeout(() => monacoEditor.refresh(), 100);
if (show) schedulePreviewSync();
}
function ensureFullPreviewLayout() {
if (!state.showPreview) return;
$('#form-panel').style.width = '480px';
$('#preview-panel').style.width = '1120px';
if (monacoReady) setTimeout(() => monacoEditor.refresh(), 100);
reMountSwagger();
}
function restoreNormalPreviewLayout() {
if (!state.showPreview) return;
$('#form-panel').style.width = '960px';
$('#preview-panel').style.width = '640px';
if (monacoReady) setTimeout(() => monacoEditor.refresh(), 100);
}
// ============================================================
// 16. Export
// ============================================================
function downloadSpec(format) {
const spec = buildOpenApiSpec();
if (format === 'yaml') { doDownload(jsyaml.dump(spec, { noRefs: true }), 'text/yaml', 'yaml'); return; }
if (format === 'json') { doDownload(JSON.stringify(spec, null, 2), 'application/json', 'json'); return; }
// html — 폐쇄망 자립: 벤더 swagger css/js 를 fetch 해 인라인
var base = (window.DJB_CTX && window.DJB_CTX.swaggerBase) || '';
Promise.all([
fetch(base + 'swagger-ui.css').then(r => r.text()),
fetch(base + 'swagger-ui-bundle.js').then(r => r.text())
]).then(function (a) {
doDownload(buildStandaloneHtml(spec, a[0], a[1]), 'text/html', 'html');
}).catch(function (e) { toast('HTML 번들 생성 실패: ' + e.message, 'error'); });
}
function doDownload(content, mime, ext) {
const blob = new Blob([content], { type: mime });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `openapi-${state.data.info.title.value || 'api'}.${ext}`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
toast(`${ext.toUpperCase()} 다운로드를 시작했습니다`);
}
// 벤더 swagger 를 인라인한 완전 자립(offline) HTML 번들
function buildStandaloneHtml(spec, css, js) {
return `<!doctype html><html lang="${state.data.docOptions.lang}"><head><meta charset="utf-8" />
<title>${escapeHtml(spec.info.title)} API 문서</title>
<style>${css}</style>
</head><body><div id="ui"></div>
<script>${js}<\/script>
<script>SwaggerUIBundle({spec:${JSON.stringify(spec)},dom_id:'#ui'});<\/script>
</body></html>`;
}
// ============================================================
// 17. 전역 인터랙션 바인딩
// ============================================================
function bindGlobalEvents() {
$('#btn-prev').addEventListener('click', () => goStep(state.step - 1));
$('#btn-next').addEventListener('click', () => {
if (state.step < STEPS.length) goStep(state.step + 1);
else toast('모든 단계를 완료했습니다. 우측 미리보기 또는 내보내기를 사용하세요.');
});
$('#btn-toggle-preview').addEventListener('click', () => setPreviewVisibility(false));
$('#btn-toggle-preview-from-form').addEventListener('click', () => setPreviewVisibility(true));
$$('.preview-tab').forEach(b => b.addEventListener('click', () => setPreviewTab(b.dataset.ptab)));
$('#btn-yaml-apply').addEventListener('click', () => {
if (!monacoReady) return;
try {
const parsed = jsyaml.load(monacoEditor.getValue());
if (!parsed || !parsed.info) throw new Error('info 필드가 없습니다');
// 전체 reverse-sync: 편집한 스펙 → state.data 전면 재구성 (specToData 역변환).
// docOptions 는 OpenAPI 에 없는 렌더 옵션이라 사용자 값 보존.
const keepDoc = state.data.docOptions;
state.data = specToData(parsed);
state.data.docOptions = keepDoc;
state.responseStatusTab = state.data.responses && state.data.responses['200']
? '200' : (state.data.responses ? (Object.keys(state.data.responses)[0] || '200') : '200');
toast('편집한 스펙을 Form 전체에 적용했습니다.');
renderForm();
setPreviewTab('swagger');
} catch (err) {
toast('YAML/스펙 파싱 오류: ' + err.message, 'error');
}
});
$('#btn-yaml-revert').addEventListener('click', () => {
updateMonacoFromState();
setPreviewTab('yaml-ro'); // 변경 취소 → YAML 보기로 전환(편집 잠금 해제)
toast('변경을 취소했습니다');
});
$('#btn-export').addEventListener('click', () => $('#export-menu').classList.toggle('hidden'));
document.addEventListener('click', e => {
if (!e.target.closest('#btn-export') && !e.target.closest('#export-menu')) $('#export-menu').classList.add('hidden');
});
$('#export-menu').addEventListener('click', e => {
const b = e.target.closest('[data-export]');
if (b) { downloadSpec(b.dataset.export); $('#export-menu').classList.add('hidden'); }
});
var bSave = $('#btn-save'); if (bSave) bSave.addEventListener('click', () => saveSpec(false));
var bRegen = $('#btn-regen'); if (bRegen) bRegen.addEventListener('click', regenerate);
var bClose = $('#btn-close'); if (bClose) bClose.addEventListener('click', () => window.close());
// Cmd/Ctrl + S → 저장 (브라우저 기본 저장 대화상자 차단)
document.addEventListener('keydown', function (e) {
if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
saveSpec(false);
}
});
// iframe(모달)로 열렸을 때만 "새창으로 띄우기" 노출 — 현재 URL 을 새 창으로 열고 모달 닫기
if (window.self !== window.top) {
var bNew = $('#btn-newwin');
if (bNew) {
bNew.classList.remove('hidden');
bNew.addEventListener('click', function () { window.open(window.location.href, '_blank'); try { window.close(); } catch (e) {} });
}
}
}
// ============================================================
// 17b. 서버 연동 (DETAIL_SPEC 로드 / SAVE) + OpenAPI → state.data 변환
// ============================================================
function wrap(v, locked) { return { value: v == null ? '' : v, locked: !!locked }; }
function exToStr(v) { return v == null ? '' : (typeof v === 'string' ? v : JSON.stringify(v, null, 2)); }
function toForm(o) { return Object.keys(o).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(o[k] == null ? '' : o[k])).join('&'); }
function propToRow(name, p, required) {
p = p || {};
var t = p.type || 'string';
var gw = !!p['x-djb-gw']; // GW 레이아웃 유래 필드 → 항목명/타입만 잠금. 필수는 잠금이어도 편집 가능.
var row = {
name: wrap(name, gw), type: wrap(t, gw), required: wrap(!!required, false),
format: wrap(p.format || ''), maxLength: wrap(p.maxLength == null ? '' : p.maxLength),
pattern: wrap(p.pattern || ''), description: wrap(p.description || ''),
example: wrap(p.example == null ? '' : p.example), children: null
};
if (t === 'object') row.children = schemaToRows(p);
else if (t === 'array') row.itemsType = wrap((p.items && p.items.type) || 'string');
return row;
}
function schemaToRows(schema) {
if (!schema || !schema.properties) return [];
var req = schema.required || [];
return Object.keys(schema.properties).map(k => propToRow(k, schema.properties[k], req.indexOf(k) >= 0));
}
// OpenAPI(3.0) → POC state.data 형태로 변환. 구조 기본값은 SAMPLE_DATA 에서 가져와 유지.
function specToData(spec) {
var d = deepClone(window.SAMPLE_DATA);
if (!spec || typeof spec !== 'object') return d;
d.__openapi = spec.openapi || '3.1.0'; // openapi 버전 보존(3.0/3.1 모두), 기본 3.1.0
d.portal = { displayYn: 'Y', roles: { ROLE_USER: false, ROLE_CORP_USER: false, ROLE_CORP_MANAGER: false }, displayOrg: '', orgs: [], apiGroups: [], responseType: 'sample', mockUrl: '' };
d.msgSpec = { request: '', response: '' }; // 요청/응답 메시지 스펙(HTML, API 소개 페이지용) = apiRequestSpec/apiResponseSpec
var info = spec.info || {};
d.info.title = wrap(info.title || '', false);
d.info.version = wrap(info.version || '1.0.0', false);
d.info.description = wrap(info.description || '');
d.info.summary = wrap((spec.tags && spec.tags[0] && spec.tags[0].description) || info.title || '');
d.info.termsOfService = wrap(info.termsOfService || '');
d.info.contact = { name: wrap(info.contact && info.contact.name || ''), email: wrap(info.contact && info.contact.email || ''), url: wrap(info.contact && info.contact.url || '') };
d.info.license = { name: wrap(info.license && info.license.name || ''), url: wrap(info.license && info.license.url || '') };
d.tags = (spec.tags || []).map(t => ({ name: t.name, description: t.description || '' }));
d.externalDocs = { description: wrap(spec.externalDocs && spec.externalDocs.description || ''), url: wrap(spec.externalDocs && spec.externalDocs.url || '') };
d.servers = (spec.servers || []).map(s => ({ url: wrap(s.url, true), env: '', description: wrap(s.description || '') }));
d.serverVariables = [];
var paths = spec.paths || {}; var pk = Object.keys(paths)[0] || '';
var pi = paths[pk] || {}; var methods = ['get', 'post', 'put', 'delete', 'patch', 'options', 'head'];
var mk = methods.filter(m => pi[m])[0] || 'get'; var op = pi[mk] || {};
d.operation = {
method: wrap((mk || 'get').toUpperCase(), true), path: wrap(pk, true),
operationId: wrap(op.operationId || ''), summary: wrap(op.summary || ''),
description: wrap(op.description || ''), tags: { value: op.tags || [], locked: false },
deprecated: { value: !!op.deprecated, locked: false }
};
d.parameters = (op.parameters || []).map(function (p) {
var s = p.schema || {};
return { in: p.in || 'query', name: wrap(p.name), type: wrap(s.type || 'string'), required: wrap(!!p.required), format: wrap(s.format || ''), maxLength: wrap(s.maxLength == null ? '' : s.maxLength), pattern: wrap(s.pattern || ''), description: wrap(p.description || ''), example: wrap(p.example == null ? '' : p.example) };
});
var rb = op.requestBody || {}; var rbc = rb.content || {}; var mt = Object.keys(rbc)[0] || 'application/json';
d.requestBody = { mediaType: mt, required: wrap(!!rb.required), schema: schemaToRows((rbc[mt] || {}).schema) };
d.responses = {};
var resp = op.responses || {};
var codes = Object.keys(resp);
if (!codes.length) { d.responses = { '200': { description: wrap('OK'), schema: [] } }; }
else codes.forEach(function (code) {
var r = resp[code] || {}; var c = r.content || {}; var m2 = Object.keys(c)[0];
d.responses[code] = { description: wrap(r.description || ''), schema: schemaToRows(m2 ? c[m2].schema : null) };
});
d.responseStatusTab = d.responses['200'] ? '200' : Object.keys(d.responses)[0];
var comps = spec.components || {}; var ss = comps.securitySchemes || {};
d.securitySchemes = Object.keys(ss).map(function (key) {
var s = ss[key] || {}; var o = { key: key, type: s.type || 'apiKey', locked: false, description: wrap(s.description || '') };
if (s.type === 'apiKey') { o.name = wrap(s.name || ''); o.in = wrap(s.in || 'header'); }
else if (s.type === 'http') { o.scheme = wrap(s.scheme || 'bearer'); o.bearerFormat = wrap(s.bearerFormat || ''); }
else if (s.type === 'oauth2') { o.flows = s.flows || {}; }
else if (s.type === 'openIdConnect') { o.openIdConnectUrl = wrap(s.openIdConnectUrl || ''); }
return o;
});
d.globalSecurity = (spec.security || []).map(r => Object.keys(r)[0]).filter(Boolean);
// examples — 상태코드별 { body, headers[] } 세트
d.examples = { request: {}, response: {} };
d.examples.request[mt] = { default: exToStr((rbc[mt] || {}).example) };
Object.keys(d.responses).forEach(function (code) {
var r = resp[code] || {}; var c = r.content || {}; var m2 = Object.keys(c)[0];
var headers = [];
if (r.headers) {
Object.keys(r.headers).forEach(function (hn) {
var h = r.headers[hn] || {};
headers.push({ name: hn, type: (h.schema && h.schema.type) || 'string', description: h.description || '', example: h.example == null ? '' : String(h.example) });
});
}
d.examples.response[code] = { body: exToStr(m2 ? c[m2].example : ''), headers: headers };
});
return d;
}
// 스키마 기반 샘플 JSON 생성 (자동생성 예제)
function _coerce(t, v) { if (t === 'integer') return parseInt(v, 10) || 0; if (t === 'number') return parseFloat(v) || 0; if (t === 'boolean') return v === true || v === 'true'; return v; }
// 기본 예제값: string→sample_명(길이 지정 시 맞춰 자름), integer→123456, number→123456.123, boolean→false
// - 숫자는 길이 지정 시 정수부를 그 길이에 맞춰 자름(실수는 소수 .123 유지)
function _defScalar(t, name, maxLen) {
var ml = parseInt(maxLen, 10);
if (t === 'integer') {
var iv = '123456';
if (ml && ml > 0 && iv.length > ml) iv = iv.substring(0, ml);
return parseInt(iv, 10);
}
if (t === 'number') {
var ip = '123456';
if (ml && ml > 0 && ip.length > ml) ip = ip.substring(0, ml);
return parseFloat(ip + '.123');
}
if (t === 'boolean') return false;
var v = 'sample_' + (name || '');
if (ml && ml > 0 && v.length > ml) v = v.substring(0, ml);
return v;
}
function _sampleVal(row) {
var t = row.type.value, ex = row.example && row.example.value;
if (t === 'object') return _sampleObj(row.children || []);
if (t === 'array') { var it = (row.itemsType && row.itemsType.value) || 'string'; return [it === 'object' ? _sampleObj(row.children || []) : _defScalar(it, (row.name && row.name.value) || 'item', '')]; }
if (ex !== undefined && ex !== '') return _coerce(t, ex);
return _defScalar(t, (row.name && row.name.value) || '', row.maxLength && row.maxLength.value);
}
function _sampleObj(rows) { var o = {}; (rows || []).forEach(function (r) { if (r && r.name && r.name.value) o[r.name.value] = _sampleVal(r); }); return o; }
function ensureExamplesFromSchema(force) {
var d = state.data;
d.examples = d.examples || { request: {}, response: {}, responseHeaders: [] };
var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json';
d.examples.request = d.examples.request || {};
var curReq = d.examples.request[mt] && d.examples.request[mt].default;
if (force || !curReq) {
d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) };
}
d.examples.response = d.examples.response || {};
if (d.responses && d.responses['200'] && (force || !d.examples.response['200'])) {
d.examples.response['200'] = JSON.stringify(_sampleObj(d.responses['200'].schema || []), null, 2);
}
}
// 예제 재생성 — 현재 편집 중(메모리) 스키마 기준 + 자동생성과 동일 값 규칙(D10). 추가/미저장 필드 반영.
function regenReqExample() {
var d = state.data;
var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json';
d.examples.request = d.examples.request || {};
d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) };
renderForm(); schedulePreviewSync();
toast('요청 예제 재생성(현재 스키마 기준)');
}
function regenResExample() {
var d = state.data;
var code = state.responseStatusTab;
var r = d.responses[code] || {};
var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] });
set.body = JSON.stringify(_sampleObj(r.schema || []), null, 2);
renderForm(); schedulePreviewSync();
toast(code + ' 응답 예제 재생성(현재 스키마 기준)');
}
// 스키마 각 스칼라 필드의 '예제값'(row.example.value) 을 기본 규칙(_defScalar)으로 채움(재귀).
function fillSchemaExamples(rows) {
(rows || []).forEach(function (r) {
if (!r || !r.name) return;
var t = r.type.value;
if (t === 'object' || t === 'array') {
if (r.children && r.children.length) fillSchemaExamples(r.children);
} else {
r.example = r.example || { value: '', locked: false };
r.example.value = String(_defScalar(t, r.name.value || '', r.maxLength && r.maxLength.value));
}
});
}
// 자동생성(초기화) 시 백엔드가 준 기초 스펙 위에, 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성.
// 재생성 버튼(_sampleObj / schemaToSpecHtml)과 동일 로직 → 자동생성 결과 == 재생성 결과.
function frontAutoGenerate() {
var d = state.data;
var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json';
// 1) 스키마 필드별 예제값 채움 (요청 + 모든 응답)
fillSchemaExamples(d.requestBody ? d.requestBody.schema : []);
Object.keys(d.responses || {}).forEach(function (code) { fillSchemaExamples((d.responses[code] || {}).schema || []); });
// 2) 요청/응답 본문 예제 JSON (채워진 예제값 사용)
d.examples = d.examples || { request: {}, response: {} };
d.examples.request = d.examples.request || {};
d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) };
d.examples.response = d.examples.response || {};
Object.keys(d.responses || {}).forEach(function (code) {
var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] });
set.body = JSON.stringify(_sampleObj((d.responses[code] || {}).schema || []), null, 2);
});
d.msgSpec = d.msgSpec || { request: '', response: '' };
d.msgSpec.request = schemaToSpecHtml(d.requestBody ? d.requestBody.schema : []);
var r200 = (d.responses && (d.responses['200'] || d.responses[Object.keys(d.responses)[0]])) || {};
d.msgSpec.response = schemaToSpecHtml(r200.schema || []);
}
// 공개 법인 선택 팝업(기존 apiSpecManDisplayOrgPopup 재사용) — 자체 iframe 모달. 팝업이 window.parent.selectOrg 호출.
// 공통 iframe 피커 모달(법인/그룹). 팝업이 same-origin 이라 window.parent.<callback> 호출 + close shim 으로 닫기.
function openPickerModal(url, title) {
closePickerModal();
var ov = document.createElement('div');
ov.id = 'picker-modal';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.45);z-index:1000;display:flex;align-items:center;justify-content:center;';
ov.innerHTML = '<div style="background:#fff;width:480px;height:600px;border-radius:8px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 10px 40px rgba(0,0,0,.3);">'
+ '<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 12px;border-bottom:1px solid #e2e8f0;font-weight:600;">' + escapeHtml(title) + ' <button type="button" id="picker-modal-x" style="border:0;background:none;font-size:16px;cursor:pointer;">✕</button></div>'
+ '<iframe src="' + url + '" style="flex:1;border:0;width:100%;"></iframe></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function (e) { if (e.target === ov) closePickerModal(); });
var x = ov.querySelector('#picker-modal-x'); if (x) x.addEventListener('click', closePickerModal);
var ifr = ov.querySelector('iframe');
if (ifr) ifr.addEventListener('load', function () { try { ifr.contentWindow.close = function () { closePickerModal(); }; } catch (e) {} });
}
function closePickerModal() { var ov = document.getElementById('picker-modal'); if (ov && ov.parentNode) ov.parentNode.removeChild(ov); }
function openOrgPopup() {
var ctx = window.DJB_CTX || {};
if (!ctx.orgPopupUrl) { toast('법인 팝업을 열 수 없습니다', 'error'); return; }
var menuId = new URLSearchParams(location.search).get('menuId') || '';
var ids = (state.data.portal.orgs || []).map(function (o) { return o.id; }).join(',');
openPickerModal(ctx.orgPopupUrl + '?cmd=POPUP_LIST&serviceType=APIGW&menuId=' + encodeURIComponent(menuId) + '&selectedOrgIds=' + encodeURIComponent(ids), '공개 법인 선택');
}
window.selectOrg = function (orgs) {
var p = state.data.portal;
p.orgs = orgs || [];
p.displayOrg = p.orgs.map(function (o) { return o.id; }).join(',');
closePickerModal();
renderForm();
};
function openGroupPopup() {
var ctx = window.DJB_CTX || {};
if (!ctx.groupPopupUrl) { toast('그룹 팝업을 열 수 없습니다', 'error'); return; }
var menuId = new URLSearchParams(location.search).get('menuId') || '';
var ids = (state.data.portal.apiGroups || []).map(function (g) { return g.id; }).join(',');
openPickerModal(ctx.groupPopupUrl + '&serviceType=APIGW&menuId=' + encodeURIComponent(menuId) + '&selectedGroupIds=' + encodeURIComponent(ids), 'API 그룹 선택');
}
window.selectApiGroup = function (groups) {
state.data.portal.apiGroups = groups || [];
closePickerModal();
renderForm();
};
function loadInitialData() {
var ctx = window.DJB_CTX || {};
if (!ctx.eaiSvcName || !ctx.detailUrl) return Promise.resolve();
return fetch(ctx.detailUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: 'eaiSvcName=' + encodeURIComponent(ctx.eaiSvcName) })
.then(r => r.json())
.then(function (res) {
if (res && res.testbedSpec) {
try { state.data = specToData(JSON.parse(res.testbedSpec)); } catch (e) { console.error('specToData', e); }
}
// 자동생성 규칙(title/tag/summary=API명, 예제, 서버prepend, opId)은 백엔드에서 적용됨 → 그대로 로드
state.step = 1; state.responseStatusTab = state.data.responses && state.data.responses['200'] ? '200' : (state.data.responses ? Object.keys(state.data.responses)[0] : '200');
var _nm = decodeEntities(ctx.apiName || '') || state.data.info.title.value || '';
var hp = $('#hdr-project-name'); if (hp) hp.textContent = ctx.eaiSvcName + (_nm ? ' - ' + _nm : ''); // 인터페이스ID - 이름
var hv = $('#hdr-version'); if (hv) hv.textContent = 'v' + (state.data.info.version.value || '1.0.0');
var badge = $('#hdr-source'); if (badge && res.source) { badge.textContent = res.source === 'saved' ? '저장본' : '자동생성'; badge.classList.remove('hidden'); }
// 포탈 게시 정보 — 기존 API 스펙 컬럼값 적용
var pp = state.data.portal;
if (res.displayYn) pp.displayYn = res.displayYn;
if (res.displayRoleCode) res.displayRoleCode.split(',').forEach(function (rc) { rc = rc.trim(); if (pp.roles.hasOwnProperty(rc)) pp.roles[rc] = true; });
if (res.displayOrg) {
pp.displayOrg = res.displayOrg;
pp.orgs = res.displayOrg.split(',').map(function (id) { id = id.trim(); return { id: id, orgName: id }; }).filter(function (o) { return o.id; });
}
if (res.responseType) pp.responseType = res.responseType;
if (res.mockUrl) pp.mockUrl = decodeEntities(res.mockUrl);
if (res.apiGroups) { try { pp.apiGroups = JSON.parse(res.apiGroups) || []; } catch (e) { pp.apiGroups = []; } }
// 요청/응답 메시지 스펙(HTML) — 저장본은 저장값, 자동생성분은 프론트에서 생성
state.data.msgSpec = state.data.msgSpec || { request: '', response: '' };
if (res.apiRequestSpec) state.data.msgSpec.request = decodeEntities(res.apiRequestSpec);
if (res.apiResponseSpec) state.data.msgSpec.response = decodeEntities(res.apiResponseSpec);
// 저장본이 아니면(자동생성/최초 생성) 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성
if ((res.source || '') !== 'saved') frontAutoGenerate();
})
.catch(e => console.error('loadInitialData', e));
}
function saveSpec(temp) {
var ctx = window.DJB_CTX || {};
if (!ctx.saveUrl || !ctx.eaiSvcName) { toast('저장 대상이 없습니다', 'error'); return; }
var spec = buildOpenApiSpec();
var d = state.data;
var reqEx = (d.examples && d.examples.request && d.examples.request[d.requestBody && d.requestBody.mediaType || 'application/json'] || {}).default || '';
var resEx = (d.examples && d.examples.response && d.examples.response['200'] && d.examples.response['200'].body) || '';
var params = {
apiId: ctx.eaiSvcName,
apiName: d.info.title.value || ctx.eaiSvcName,
apiSimpleDescription: (d.info.summary && d.info.summary.value) || '',
apiUrl: d.operation.path.value || '',
apiMethod: d.operation.method.value || 'GET',
apiContentType: (d.requestBody && d.requestBody.mediaType) || 'application/json',
description: d.info.description.value || '',
testbedSpec: JSON.stringify(spec),
sampleRequest: reqEx,
sampleResponse: resEx,
// 요청/응답 메시지 스펙(HTML, API 소개 페이지용)
apiRequestSpec: (d.msgSpec && d.msgSpec.request) || '',
apiResponseSpec: (d.msgSpec && d.msgSpec.response) || '',
// 포탈 게시 정보
displayYn: (d.portal && d.portal.displayYn) || 'N',
displayRoleCode: d.portal ? Object.keys(d.portal.roles).filter(function (k) { return d.portal.roles[k]; }).join(',') : '',
displayOrg: (d.portal && d.portal.displayOrg) || '',
responseType: (d.portal && d.portal.responseType) || 'sample',
mockUrl: (d.portal && d.portal.mockUrl) || '',
apiGroupIds: (d.portal && d.portal.apiGroups) ? d.portal.apiGroups.map(function (g) { return g.id; }).join(',') : ''
};
fetch(ctx.saveUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: toForm(params) })
.then(r => r.json())
.then(function (res) {
if (res && res.status === 'success') {
toast(temp ? '임시저장되었습니다' : '저장되었습니다');
var badge = $('#hdr-source'); if (badge) { badge.textContent = '저장본'; badge.className = 'ml-1 px-2 py-0.5 text-[11px] rounded-full bg-emerald-50 text-emerald-700 border border-emerald-200'; badge.classList.remove('hidden'); }
} else toast('저장 실패: ' + (res && res.errorMsg || ''), 'error');
})
.catch(e => toast('저장 실패: ' + e.message, 'error'));
}
// 자동 생성(초기화): 현재 입력을 무시하고 게이트웨이 레이아웃 기반으로 강제 재생성(regen=true)
function regenerate() {
var ctx = window.DJB_CTX || {};
if (!ctx.eaiSvcName || !ctx.detailUrl) { toast('대상이 없습니다', 'error'); return; }
if (!window.confirm('현재 입력값을 모두 무시하고 게이트웨이 레이아웃 기반으로 다시 자동 생성합니다. 계속할까요?')) return;
fetch(ctx.detailUrl + '&regen=true', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: 'eaiSvcName=' + encodeURIComponent(ctx.eaiSvcName) })
.then(r => r.json())
.then(function (res) {
if (res && res.testbedSpec) {
try { state.data = specToData(JSON.parse(res.testbedSpec)); }
catch (e) { toast('자동 생성 실패: 스펙 파싱 오류', 'error'); return; }
}
// 구조(title/tag/summary/opId/스키마/보안/헤더)는 백엔드(generateSpec)가 적용한 기초 스펙 그대로 로드.
// 추가자료(예제·설명자료)는 프론트에서 스키마 기준 생성 → 재생성 버튼과 동일 결과.
frontAutoGenerate();
state.step = 1;
state.responseStatusTab = state.data.responses && state.data.responses['200'] ? '200' : (state.data.responses ? (Object.keys(state.data.responses)[0] || '200') : '200');
var badge = $('#hdr-source'); if (badge) { badge.textContent = '자동생성'; badge.classList.remove('hidden'); }
renderStepper(); renderForm(); setPreviewTab('swagger'); reMountSwagger();
toast('게이트웨이 레이아웃 기반으로 다시 생성했습니다.');
})
.catch(e => toast('자동 생성 실패: ' + e.message, 'error'));
}
// ============================================================
// 18. Init
// ============================================================
function init() {
loadInitialData().then(function () {
renderStepper();
renderForm();
bindGlobalEvents();
bindFormEvents();
mountSwagger();
initMonaco().then(updateMonacoFromState);
});
}
document.addEventListener('DOMContentLoaded', init);
})();