/* 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
// CrossScriptingFilter 가 엔티티(( 등)로 치환한 값 복원. 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 `
🔒
`;
}
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 ``;
}
function textarea(value, dataAttr, opts) {
opts = opts || {};
return ``;
}
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 `${escapeHtml(l)} `;
}).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 ``;
}
function checkbox(checked, dataAttr, opts) {
opts = opts || {};
const locked = opts.locked ? 'disabled' : '';
return ` `;
}
// ============================================================
// 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 `${escapeHtml(title)} ${
hint ? `
${escapeHtml(hint)}
` : ''}
`;
}
function fieldRow(label, controlHtml, opts) {
opts = opts || {};
return `
${escapeHtml(label)}${opts.required ? ' * ' : ''}
${opts.locked ? '🔒 ' : ''}
${controlHtml}${opts.hint ? `
${escapeHtml(opts.hint)}
` : ''}
`;
}
function card(html) {
return `${html}
`;
}
// ============================================================
// 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 정보', '게이트웨이가 제공한 식별 정보와 사용자가 작성하는 설명을 분리해 표시합니다.') +
`
${escapeHtml(op.method.value)}
${escapeHtml(op.path.value)}
🔒 게이트웨이에서 자동 동기화
` +
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: '동작, 비즈니스 규칙, 주의사항' }) + 'Markdown 문법 사용 가능
')
)}
${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 '등록된 태그가 없습니다 ';
return state.data.tags.map((t, i) =>
`
${escapeHtml(t.name)}${t.description ? ' · ' + escapeHtml(t.description) : ''}
✕
`
).join('');
}
// (구 Step 2 엔드포인트는 기본정보 카드로 병합됨)
function renderOpTagSelector(selected) {
return `
${state.data.tags.map(t => `
${escapeHtml(t.name)}
`).join('')}
${state.data.tags.length === 0 ? '먼저 Step 1 에서 태그를 등록하세요 ' : ''}
`;
}
// ============================================================
// 8. Step 3 — 파라미터 / 스키마 (핵심)
// ============================================================
// 서브 탭 바 (요청/응답 정보 공용)
function subTabBar(tabs, cur, attr) {
return ''
+ tabs.map(t => `${t.label} `).join('')
+ '
';
}
// 요청 정보 진입 시 먼저 보일 서브탭 = 값이 든 첫 탭(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 소개(설명) 페이지에 표시되는 자료입니다.') +
`'
);
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 소개(설명) 페이지에 표시되는 자료입니다.') +
`'
);
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/[요청/응답 샘플]과 연동됩니다.') +
`
${codes.map(code => `
${escapeHtml(code)}${code !== '200' ? ` ✕ ` : ''}
`).join('')}
+ 상태코드
` +
`
name type description example
${(curSet.headers || []).map((h, i) => `
${input(h.name, `data-resp-header="${i}.name"`)}
${selectInput(h.type, ['string', 'integer', 'number', 'boolean'], `data-resp-header="${i}.type"`)}
${input(h.description, `data-resp-header="${i}.description"`)}
${input(h.example, `data-resp-header="${i}.example"`)}
✕
`).join('')}
`
);
}
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 `
${p.name.locked ? lockedInput(p.name.value) : input(p.name.value, `data-path="parameters.${i}.name.value"`)}
${input(p.description.value, `data-path="parameters.${i}.description.value"`, { placeholder: '설명' })}
${p.type.locked
? lockedInput(p.type.value)
: selectInput(p.type.value, ['string', 'integer', 'number', 'boolean'], `data-path="parameters.${i}.type.value"`)}
${input(p.maxLength.value, `data-path="parameters.${i}.maxLength.value"`, { placeholder: '0', extra: 'text-xs' })}
${checkbox(p.required.value, `data-path="parameters.${i}.required.value" data-type="bool"`, { locked: p.required.locked })}
${input(p.example.value, `data-path="parameters.${i}.example.value"`, { placeholder: '예제값' })}
${p.name.locked ? '' : `✕ `}
`;
}).join('');
return card(
sectionTitle(isHeader ? '요청 Header' : 'Parameter (Query)', '게이트웨이가 등록한 항목은 잠금됩니다. 추가 항목은 자유롭게 정의하세요.') +
`
이름
설명
타입
길이
필수
예제값
${rows || `등록된 항목이 없습니다 `}
+ ${isHeader ? '헤더' : '파라미터'} 추가
`
);
}
// --- Body 스키마 테이블 ---
function renderBodyTable() {
return card(
sectionTitle('Request Body 스키마', `media type: ${state.data.requestBody.mediaType}. 중첩된 object/array 를 모두 지원합니다.`) +
`
기본 동작과 잠금
여기서 설정하는 validation(타입·필수·제약 등)은 실제 게이트웨이(GW) 동작에는 영향을 주지 않습니다. 개발자 포털과 OpenAPI Spec 문서에만 반영되는 참고 정보입니다.
🔒 표시된 필드 는 GW 레이아웃에 등록된 필드 로, 삭제하거나 속성(이름·타입·필수 등)을 변경할 수 없습니다. 추가 필드는 자유롭게 정의·삭제할 수 있습니다.
` +
renderSchemaTable(state.data.requestBody.schema, 'requestBody.schema') +
`
+ 최상위 필드 추가
`
);
}
// --- 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 => `
${escapeHtml(code)}${code !== '200' ? ` ✕ ` : ''}
`).join('');
return card(
sectionTitle('Response 스키마', '상태 코드별로 응답 스키마를 정의합니다. 추가한 상태코드는 [요청/응답 샘플]의 응답 예제와 연동됩니다.') +
`${tabsHtml}
+ 상태코드
` +
`${fieldRow('설명', input(r.description.value, `data-path="responses.${cur}.description.value"`, { placeholder: '응답 설명' }))}
` +
renderSchemaTable(r.schema, `responses.${cur}.schema`) +
`
+ 필드 추가
`
);
}
function renderComponentsArea() {
return card(
sectionTitle('Components / 재사용 스키마', '여러 오퍼레이션에서 공유할 스키마를 정의합니다.') +
`
등록된 컴포넌트가 없습니다. + 새 스키마 정의
`
);
}
// 중첩 스키마 테이블 렌더 (재귀)
function renderSchemaTable(rows, basePath) {
return `
#
잠금
필드명
설명
타입
길이
필수
예제값
${renderSchemaRows(rows, basePath, 0, '')}
`;
}
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 += `
${seq}
${isLocked ? '🔒 ' : ''}
${isObject || isArray
? `${isExpanded ? '▾' : '▸'} `
: ` `}
${isObject ? '{}' : isArray ? '[]' : '·'}
${isLocked
? ` `
: input(row.name.value, `data-path="${path}.name.value"`, { extra: 'flex-1' })}
${input(row.description.value, `data-path="${path}.description.value"`, { placeholder: '설명' })}
${row.type.locked
? ` `
: selectInput(row.type.value,
['string', 'integer', 'number', 'boolean', 'array', 'object'],
`data-path="${path}.type.value" data-rerender="step3"`)}
${row.type.value === 'string'
? input(row.maxLength.value, `data-path="${path}.maxLength.value"`, { placeholder: '0', extra: 'text-xs' })
: '— '}
${checkbox(!!row.required.value, `data-path="${path}.required.value" data-type="bool"`, { locked: row.required.locked })}
${isObject ? '— (자식 참조) ' :
isArray
? `
items:
${selectInput((row.itemsType && row.itemsType.value) || 'string', ['string', 'integer', 'number', 'boolean'], `data-path="${path}.itemsType.value"`, { extra: 'text-xs' })}
`
: input(row.example.value, `data-path="${path}.example.value"`, { placeholder: '예제값' })}
${isLocked
? ''
: `✕ `}
`;
// children
if (isObject && isExpanded) {
if (row.children && row.children.length) {
html += renderSchemaRows(row.children, `${path}.children`, depth + 1, seq);
}
html += `
+ 자식 필드 추가
`;
}
});
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('')
: '인증 없음
')
);
}
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 = `
name ${lockedInput(s.name ? s.name.value : '')}
in ${lockedInput(s.in ? s.in.value : 'header')}
`;
} else if (s.type === 'http') {
body = `
scheme ${lockedInput(s.scheme ? s.scheme.value : '')}
bearerFormat ${lockedInput((s.bearerFormat && s.bearerFormat.value) || '')}
`;
} else if (s.type === 'oauth2') {
body = `OAuth2
`;
} else if (s.type === 'openIdConnect') {
body = `openIdConnectUrl ${lockedInput(s.openIdConnectUrl ? s.openIdConnectUrl.value : '')}
`;
}
return `
${escapeHtml(s.key)}
${escapeHtml(s.type)}
🔒 GW
${body}
${(s.description && s.description.value) ? `
${escapeHtml(s.description.value)}
` : ''}
`;
}
// ============================================================
// 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)') +
`스키마로 재생성
` +
`` +
`스키마와 자동 매핑되어 Swagger UI 의 예제 영역에 노출됩니다.
`
);
} 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('응답 예제 (읽기 전용)', '응답 정보(스키마·예제값)에서 자동 생성됩니다. 수정은 [응답 정보] 탭에서 하세요.') +
`
${codes.map(code => `
${escapeHtml(code)}
`).join('')}
` +
`응답 본문 (${escapeHtml(cur)}) ` +
`` +
`응답 헤더는 [응답 정보 > Header] 에서 관리합니다.
`
);
}
root.innerHTML = subTabBar(tabs, tab, 'data-sample-tab') + body;
initExampleEditors(tab); // 요청=편집 CodeMirror, 응답=읽기전용 CodeMirror
}
// (구 요청/응답(웹페이지) 스텝은 요청/응답 정보의 'API 설명(HTML)' 서브 탭으로 흡수됨)
// 스키마 → 설명 자료 HTML 표(필드명/타입/필수/길이/설명). 현재 편집 중 스키마(추가 필드 포함) 기준.
function schemaToSpecHtml(rows) {
var out = ''
+ '필드명 '
+ '타입 '
+ '필수 '
+ '길이 '
+ '설명 ';
function walk(list, depth) {
(list || []).forEach(function (r) {
if (!r || !r.name) return;
var pad = new Array(depth + 1).join(' ');
var req = 'Y'; // 설명 자료 HTML: 필수 여부 기본값 = 필수(Y)
var len = (r.maxLength && r.maxLength.value) || '';
// 필드명·설명 = 좌측 정렬, 나머지(타입·필수·길이) = 가운데 정렬
out += ''
+ '' + pad + escapeHtml(r.name.value || '') + ' '
+ '' + escapeHtml(r.type.value || '') + ' '
+ '' + req + ' '
+ '' + escapeHtml(String(len)) + ' '
+ '' + escapeHtml((r.description && r.description.value) || '') + ' ';
if (r.children && r.children.length) walk(r.children, depth + 1);
});
}
walk(rows, 0);
out += '
';
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) =>
`${escapeHtml(o.orgName || o.id)}✕ `
).join('');
const groupChips = (p.apiGroups || []).map((g, i) =>
`${escapeHtml(g.groupName || g.id)}✕ `
).join('');
root.innerHTML =
card(
sectionTitle('포탈 게시 정보', '개발자포탈 공개 설정. 기존 API 스펙 화면의 값을 불러와 적용합니다.') +
fieldRow('API 그룹',
`
${groupChips || '선택된 그룹 없음 '}
+ 선택
API 그룹은 apiGroupMan(API 그룹 관리)에서도 관리됩니다.
`) +
fieldRow('공개 여부', `${checkbox(p.displayYn === 'Y', 'data-portal-yn')} 공개 `) +
fieldRow('공개 권한',
`
${checkbox(!!roles.ROLE_USER, 'data-role="ROLE_USER"')} 개인사용자
${checkbox(!!roles.ROLE_CORP_USER, 'data-role="ROLE_CORP_USER"')} 법인사용자
${checkbox(!!roles.ROLE_CORP_MANAGER, 'data-role="ROLE_CORP_MANAGER"')} 법인관리자
전부 선택 시 로그인 사용자 전체에게 공개됩니다.
`) +
(showOrg ? fieldRow('공개 법인',
`
${orgChips || '선택된 법인 없음 '}
+ 선택
특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.
`) : '') +
fieldRow('응답 유형',
'' +
['sample', 'mock', 'gw'].map(v =>
`
${v === 'gw' ? 'GW' : v}`
).join('') +
`
실제 호출 주소: ${escapeHtml(resolvedCallUrl())}
` +
(rt === 'gw' ? `
GW 주소는 포탈 설정(PTL_PROPERTY, 그룹 Portal)의 djb.gateway.base-url 값을 사용합니다.
` : '') +
'
') +
(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 스펙 파일에는 포함되지 않습니다.') +
'' + (state.data.info.description.value || '') + '
'
) +
card(
sectionTitle('내보내기', 'OpenAPI 스펙을 파일로 저장합니다.') +
`
YAML 다운로드
JSON 다운로드
`
);
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 = `
키 이름 (components 에 등록될 식별자)
타입
${['apiKey', 'http', 'oauth2', 'openIdConnect'].map(t => `
${escapeHtml(t)}
`).join('')}
`;
const detail = $('#sec-detail');
if (chosenType === 'apiKey') {
detail.innerHTML = `
`;
} else if (chosenType === 'http') {
detail.innerHTML = `
`;
} else if (chosenType === 'oauth2') {
detail.innerHTML = `
Flow 타입을 선택하세요 (POC 에서는 최소 메타만 저장)
implicit
password
clientCredentials
authorizationCode
authorizationUrl
tokenUrl
`;
} else {
detail.innerHTML = `
openIdConnectUrl
`;
}
};
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 `
${escapeHtml(spec.info.title)} API 문서