/* 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 ``; }).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 `
${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 => ` `).join('')} ${state.data.tags.length === 0 ? '먼저 Step 1 에서 태그를 등록하세요' : ''}
`; } // ============================================================ // 8. Step 3 — 파라미터 / 스키마 (핵심) // ============================================================ // 서브 탭 바 (요청/응답 정보 공용) function subTabBar(tabs, cur, attr) { return '
' + tabs.map(t => ``).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 => ` `).join('')}
` + ` ${(curSet.headers || []).map((h, i) => ` `).join('')}
nametypedescriptionexample
${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"`)}
` ); } 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 || ``}
이름 설명 타입 길이 필수 예제값
등록된 항목이 없습니다
` ); } // --- Body 스키마 테이블 --- function renderBodyTable() { return card( sectionTitle('Request Body 스키마', `media type: ${state.data.requestBody.mediaType}. 중첩된 object/array 를 모두 지원합니다.`) + `
기본 동작과 잠금
` + 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 => ` `).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 ? `` : ``} ${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 = `
${lockedInput(s.name ? s.name.value : '')}
${lockedInput(s.in ? s.in.value : 'header')}
`; } else if (s.type === 'http') { body = `
${lockedInput(s.scheme ? s.scheme.value : '')}
${lockedInput((s.bearerFormat && s.bearerFormat.value) || '')}
`; } else if (s.type === 'oauth2') { body = `
OAuth2
`; } else if (s.type === 'openIdConnect') { body = `
${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 => ` `).join('')}
` + `` + `` + `
응답 헤더는 [응답 정보 > 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 += '' + '' + '' + '' + '' + ''; if (r.children && r.children.length) walk(r.children, depth + 1); }); } walk(rows, 0); out += '
필드명타입필수길이설명
' + pad + escapeHtml(r.name.value || '') + '' + escapeHtml(r.type.value || '') + '' + req + '' + escapeHtml(String(len)) + '' + escapeHtml((r.description && r.description.value) || '') + '
'; 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('공개 여부', ``) + fieldRow('공개 권한', `
전부 선택 시 로그인 사용자 전체에게 공개됩니다.
`) + (showOrg ? fieldRow('공개 법인', `
${orgChips || '선택된 법인 없음'}
특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.
`) : '') + fieldRow('응답 유형', '
' + ['sample', 'mock', 'gw'].map(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 스펙을 파일로 저장합니다.') + `
` ); 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 = `
${['apiKey', 'http', 'oauth2', 'openIdConnect'].map(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 에서는 최소 메타만 저장)
`; } else { detail.innerHTML = ` `; } }; 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 문서