From b7c8e7f41c54893939fd96c158b8b675d34cc435 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Fri, 3 Jul 2026 16:54:47 +0900 Subject: [PATCH] =?UTF-8?q?API=20=EA=B7=B8=EB=A3=B9=20=ED=8C=9D=EC=97=85?= =?UTF-8?q?=20JSP=20=EC=B6=94=EA=B0=80=20-=20Swagger=20Validator=20JS=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20-=20Summernote=20CSS=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 그룹 팝업(djbApiGroupPopup.jsp) 화면 파일 추가 - Swagger Validator (djb-swagger-validator.js) 및 관련 리팩토링 - Summernote 콘텐츠 스타일 정의 (editor-content.css) 추가 --- WebContent/js/djb/apispec/app.js | 842 ++++++++++++------ WebContent/js/djb/apispec/editor-content.css | 334 +++++++ .../onl/apim/apigroup/apiGroupManDetail.jsp | 2 +- .../onl/apim/portalproperty/propertyMan.jsp | 10 +- .../onl/transaction/apim/apiSpecManPopup.jsp | 2 +- .../onl/transaction/apim/djbApiGroupPopup.jsp | 132 +++ .../transaction/apim/djbApiSpecManPopup.jsp | 12 +- build.gradle | 165 ++-- build.gradle.intellij | 4 +- .../onl/apim/apigroup/ApiGroupService.java | 34 + .../portalproperty/PortalPropertyMapper.java | 2 + .../onl/manage/comm/property/PropertyUI.java | 3 + .../transaction/apim/ApiSpecController.java | 2 +- .../apim/DjbApiSpecController.java | 79 +- 14 files changed, 1242 insertions(+), 381 deletions(-) create mode 100644 WebContent/js/djb/apispec/editor-content.css create mode 100644 WebContent/jsp/onl/transaction/apim/djbApiGroupPopup.jsp diff --git a/WebContent/js/djb/apispec/app.js b/WebContent/js/djb/apispec/app.js index 28dbd77..bfe9476 100644 --- a/WebContent/js/djb/apispec/app.js +++ b/WebContent/js/djb/apispec/app.js @@ -8,12 +8,11 @@ // 1. 상태 // ============================================================ const STEPS = [ - { id: 1, title: '기본정보', hint: 'API 의 메타 정보(설명, 연락처, 라이선스, 태그) 를 입력합니다.' }, - { id: 2, title: '엔드포인트', hint: '환경별 서버 URL 과 오퍼레이션 메타정보를 작성합니다.' }, - { id: 3, title: '파라미터/스키마', hint: '요청 파라미터, 본문, 응답 스키마를 정의합니다.' }, - { id: 4, title: '인증/보안', hint: 'API 인증 방식과 적용 범위를 설정합니다.' }, - { id: 5, title: '예제/응답', hint: '실제 요청/응답 예제와 헤더를 입력합니다.' }, - { id: 6, title: '포탈 게시 정보', hint: '개발자포탈 공개 설정(공개 여부/권한/법인/응답유형)을 지정합니다.' } + { 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 = { @@ -107,6 +106,7 @@ // ============================================================ function buildOpenApiSpec() { const d = state.data; + syncResponseExamplesFromSchema(); // 응답 예제 = 응답 정보(스키마·예제값) 파생 — 저장/미리보기 일관 const spec = { openapi: (d.__openapi || '3.1.0'), info: { @@ -187,13 +187,16 @@ } }); - // 서버 base = 응답유형별 단일 서버 (gw=swagger.gw.address, mock=MockURL, 그 외=레이아웃 어댑터 주소) + // 경로(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 _base; - if (_rt === 'gw') _base = (window.DJB_CTX && window.DJB_CTX.gwAddress) || 'http://127.0.0.1:39310'; - else if (_rt === 'mock') _base = (d.portal && d.portal.mockUrl) || ''; - else _base = (d.servers && d.servers[0] && d.servers[0].url && d.servers[0].url.value) || ''; - if (_base) spec.servers = [{ url: _base }]; + 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 }; // 보안 스킴 @@ -298,10 +301,12 @@ function goStep(n) { if (n < 1 || n > STEPS.length) return; state.step = n; + if (n === 2) state.reqInfoTab = 'body'; // 요청/응답 정보: 진입 시 항상 Body 먼저 + if (n === 3) state.resInfoTab = 'body'; renderStepper(); renderForm(); - if (state.step === 3) { - setPreviewVisibility(false); // 파라미터/스키마: 우측 미리보기 패널 숨김 + if (state.step === 2 || state.step === 3) { + setPreviewVisibility(false); // 요청/응답 정보(넓은 테이블): 우측 미리보기 패널 숨김 } else { setPreviewVisibility(true); restoreNormalPreviewLayout(); @@ -315,12 +320,11 @@ const root = $('#form-content'); root.innerHTML = ''; switch (state.step) { - case 1: renderStep1(root); break; - case 2: renderStep2(root); break; - case 3: renderStep3(root); break; - case 4: renderStep4(root); break; - case 5: renderStep5(root); break; - case 6: renderStep6(root); break; + 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; // 포탈 게시 정보 } } @@ -349,13 +353,26 @@ // ============================================================ 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('설명', '
' + (i.description.value || '') + '
Swagger + API 소개 페이지에 사용됨. Swagger 에서 일부 태그가 제거될 수 있음.
') + fieldRow('상세 설명', textarea(op.description.value, 'data-path="operation.description.value"', { rows: 4, placeholder: '동작, 비즈니스 규칙, 주의사항' }) + '
Markdown 문법 사용 가능
') )} ${card( @@ -363,22 +380,30 @@ 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://...' })) )} - `; - // 설명 = Summernote 리치 에디터 (jQuery 플러그인, 벤더 로드) - if (window.jQuery && window.jQuery.fn && window.jQuery.fn.summernote) { - var $desc = window.jQuery('#info-desc-editor'); - try { $desc.summernote('destroy'); } catch (e) {} - var koOk = window.jQuery.summernote && window.jQuery.summernote.lang && window.jQuery.summernote.lang['ko-KR']; - $desc.summernote({ - height: 180, - lang: koOk ? 'ko-KR' : 'en-US', - callbacks: { - onChange: function (contents) { state.data.info.description.value = contents; schedulePreviewSync(); } - } - }); - $desc.summernote('code', state.data.info.description.value || ''); - } + ${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 '등록된 태그가 없습니다'; @@ -390,35 +415,7 @@ ).join(''); } - // ============================================================ - // 7. Step 2 — 엔드포인트 / 서버 - // ============================================================ - function renderStep2(root) { - const d = state.data; - const op = d.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('오퍼레이션', '게이트웨이가 등록한 메서드/경로는 잠금됩니다.') + - `
- ${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('요약(summary)', lockedInput((d.info.title && d.info.title.value) || '') + '
기본정보의 API 타이틀에서 수정
', { locked: true }) + - fieldRow('상세 설명', textarea(op.description.value, 'data-path="operation.description.value"', { rows: 4, placeholder: '동작, 비즈니스 규칙, 주의사항' }) + '
Markdown 문법 사용 가능
') + - fieldRow('Deprecated', ``) - )} - - `; - } + // (구 Step 2 엔드포인트는 기본정보 카드로 병합됨) function renderOpTagSelector(selected) { return `
${state.data.tags.map(t => ` @@ -435,57 +432,147 @@ // ============================================================ // 8. Step 3 — 파라미터 / 스키마 (핵심) // ============================================================ - function renderStep3(root) { - // Request Body 만 접근 (파라미터 / Response / Components 미노출) - state.step3Section = 'body'; - root.innerHTML = renderBodyTable(); - } - function renderStep3Section() { - // Step3 = Request Body 만. 컨테이너는 #form-content(직접). (add-row/자식추가/토글 재렌더용) - const c = $('#form-content'); - if (!c) return; - c.innerHTML = renderBodyTable(); + // 서브 탭 바 (요청/응답 정보 공용) + function subTabBar(tabs, cur, attr) { + return '
' + + tabs.map(t => ``).join('') + + '
'; } - // --- Parameters 테이블 --- - function renderParamsTable() { - const rows = state.data.parameters.map((p, i) => ` + // 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 ` - ${selectInput(p.in, ['header', 'query', 'path', 'cookie'], `data-pparam-in="${i}"`)} ${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 })} - ${selectInput(p.format.value, ['', 'uuid', 'date', 'date-time', 'email', 'uri'], `data-path="parameters.${i}.format.value"`)} - ${input(p.maxLength.value, `data-path="parameters.${i}.maxLength.value"`, { placeholder: 'maxLength', extra: 'text-xs' })} - ${input(p.description.value, `data-path="parameters.${i}.description.value"`, { placeholder: '설명' })} - ${input(p.example.value, `data-path="parameters.${i}.example.value"`, { placeholder: '예제' })} + ${input(p.example.value, `data-path="parameters.${i}.example.value"`, { placeholder: '예제값' })} - ${p.name.locked ? '🔒' : ``} + ${p.name.locked ? '' : ``} - - `).join(''); + `; + }).join(''); return card( - sectionTitle('파라미터 (Header / Query / Path / Cookie)', '게이트웨이가 등록한 파라미터는 잠금됩니다. 추가 파라미터는 자유롭게 정의하세요.') + + sectionTitle(isHeader ? '요청 Header' : 'Parameter (Query)', '게이트웨이가 등록한 항목은 잠금됩니다. 추가 항목은 자유롭게 정의하세요.') + ` - - - - - - + + + + - ${rows} + ${rows || ``}
위치 이름타입필수formatmaxLen 설명예제타입길이필수예제값
등록된 항목이 없습니다
- +
` ); } @@ -494,10 +581,12 @@ function renderBodyTable() { return card( sectionTitle('Request Body 스키마', `media type: ${state.data.requestBody.mediaType}. 중첩된 object/array 를 모두 지원합니다.`) + - `
- - ${checkbox(state.data.requestBody.required.value, 'data-path="requestBody.required.value" data-type="bool"', { locked: state.data.requestBody.required.locked })} - 🔒 + `
+
기본 동작과 잠금
+
    +
  • 여기서 설정하는 validation(타입·필수·제약 등)은 실제 게이트웨이(GW) 동작에는 영향을 주지 않습니다. 개발자 포털과 OpenAPI Spec 문서에만 반영되는 참고 정보입니다.
  • +
  • 🔒 표시된 필드GW 레이아웃에 등록된 필드로, 삭제하거나 속성(이름·타입·필수 등)을 변경할 수 없습니다. 추가 필드는 자유롭게 정의·삭제할 수 있습니다.
  • +
` + renderSchemaTable(state.data.requestBody.schema, 'requestBody.schema') + `
@@ -514,12 +603,14 @@ const tabsHtml = codes.map(code => ` `).join(''); return card( - sectionTitle('Response 스키마', '상태 코드별로 응답 스키마를 정의합니다.') + - `
${tabsHtml}
` + + sectionTitle('Response 스키마', '상태 코드별로 응답 스키마를 정의합니다. 추가한 상태코드는 [요청/응답 샘플]의 응답 예제와 연동됩니다.') + + `
${tabsHtml} + +
` + `
${fieldRow('설명', input(r.description.value, `data-path="responses.${cur}.description.value"`, { placeholder: '응답 설명' }))}
` + renderSchemaTable(r.schema, `responses.${cur}.schema`) + `
@@ -543,38 +634,38 @@ - - - - - - + + + - + + + + - ${renderSchemaRows(rows, basePath, 0)} + ${renderSchemaRows(rows, basePath, 0, '')}
필드명타입필수formatmaxLen제약#잠금필드명 설명예제타입길이필수예제값
`; } - function renderSchemaRows(rows, basePath, depth) { + 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 hasChildren = isObject && row.children && row.children.length; const expandedKey = `${path}.__expanded`; const isExpanded = state[expandedKey] !== false; - const constraintsKey = `${path}.__constraints`; - const showConstraints = !!state[constraintsKey]; html += ` + ${seq} + ${isLocked ? '🔒' : ''}
${isObject || isArray @@ -582,40 +673,27 @@ : ``} ${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 - ? lockedInput(row.type.value) + ? `` : selectInput(row.type.value, ['string', 'integer', 'number', 'boolean', 'array', 'object'], `data-path="${path}.type.value" data-rerender="step3"`)} - - ${checkbox(!!row.required.value, `data-path="${path}.required.value" data-type="bool"`, { locked: row.required.locked })} - - - ${isObject || isArray - ? '' - : selectInput(row.format.value, ['', 'uuid', 'date', 'date-time', 'email', 'uri', 'int32', 'int64', 'float', 'double', 'byte', 'binary'], `data-path="${path}.format.value"`)} - ${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 })} - ${input(row.description.value, `data-path="${path}.description.value"`, { placeholder: '설명' })} ${isObject ? '— (자식 참조)' : isArray @@ -623,52 +701,20 @@ 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: '예제' })} + : input(row.example.value, `data-path="${path}.example.value"`, { placeholder: '예제값' })} ${isLocked - ? '🔒' + ? '' : ``} `; - // 제약 expand 행 - if (showConstraints) { - html += ` - - -
-
- - ${input(row.pattern.value, `data-path="${path}.pattern.value"`, { placeholder: '^[A-Z]+$', extra: 'text-xs' })} -
-
- - ${input((row.enumStr && row.enumStr.value) || '', `data-path="${path}.enumStr.value"`, { placeholder: 'A,B,C', extra: 'text-xs' })} -
-
- - ${input((row.minimum && row.minimum.value) || '', `data-path="${path}.minimum.value"`, { placeholder: '0', extra: 'text-xs' })} -
-
- - ${input((row.maximum && row.maximum.value) || '', `data-path="${path}.maximum.value"`, { placeholder: '100', extra: 'text-xs' })} -
-
- - ${input((row.default && row.default.value) || '', `data-path="${path}.default.value"`, { placeholder: '기본값', extra: 'text-xs' })} -
-
- - - `; - } - // children if (isObject && isExpanded) { if (row.children && row.children.length) { - html += renderSchemaRows(row.children, `${path}.children`, depth + 1); + html += renderSchemaRows(row.children, `${path}.children`, depth + 1, seq); } html += ` @@ -683,11 +729,11 @@ } // ============================================================ - // 9. Step 4 — 인증 / 보안 + // 9. 인증 / 보안 (기본정보 Step 에 병합된 섹션) // ============================================================ - function renderStep4(root) { + function renderAuthSection() { var schemes = state.data.securitySchemes || []; - root.innerHTML = card( + return card( sectionTitle('인증 / 보안', '게이트웨이(GW)에 설정된 인증만 표시됩니다. (읽기 전용)') + (schemes.length ? schemes.map((s, i) => renderSecuritySchemeCard(s, i)).join('') @@ -737,61 +783,164 @@ } // ============================================================ - // 10. Step 5 — 예제 / 응답 + // 10. Step 4 — 요청/응답 샘플 (하위 탭: 요청=편집, 응답=읽기전용(응답 정보에서 파생)) // ============================================================ - function renderStep5(root) { - const reqObj = state.data.examples.request['application/json'] || { default: '' }; - const req = reqObj.default || ''; - const codes = Object.keys(state.data.examples.response); - const cur = state.responseStatusTab; - const curSet = state.data.examples.response[cur] || { body: '', headers: [] }; - 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'; + // 응답 예제 본문을 응답 정보(스키마·예제값)에서 파생해 동기화 — 응답 샘플은 항상 응답 정보 세팅을 따른다. + 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); + }); + } - root.innerHTML = ` - ${card( + 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 의 예제 영역에 노출됩니다.
` - )} - - ${card( - sectionTitle('응답 예제', '상태코드별 본문/헤더 1세트. 기본 200 + 추가 등록 가능.') + + ); + } 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 => ` + ${cur === code ? 'border-brand-600 text-brand-700 font-medium' : 'border-transparent text-slate-600'}">${escapeHtml(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"`)}
-
- -
-
` - )} - `; + `` + + `` + + `
응답 헤더는 [응답 정보 > 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); } // ============================================================ @@ -805,9 +954,20 @@ 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('공개 권한', `
@@ -819,15 +979,26 @@
전부 선택 시 로그인 사용자 전체에게 공개됩니다.
`) + (showOrg ? fieldRow('공개 법인', - `
- ${orgChips || '선택된 법인 없음'} - + `
+
+ ${orgChips || '선택된 법인 없음'} + +
+
특정 법인에게만 공개하고 싶을 때 공개 법인을 선택하시면 됩니다.
`) : '') + fieldRow('응답 유형', + '
' + ['sample', 'mock', 'gw'].map(v => `` - ).join('')) + - (rt === 'mock' ? fieldRow('Mock URL', input(p.mockUrl || '', 'data-portal="mockUrl"', { placeholder: 'https://...' })) : '') + ).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 스펙을 파일로 저장합니다.') + @@ -836,6 +1007,7 @@
` ); + initInfoDescEditor(); // 설명 리치 에디터 (API 소개 페이지 전용) } // ============================================================ @@ -983,15 +1155,7 @@ return; } - // Response status tab - const resTab = target.closest('[data-res-tab]'); - if (resTab) { - state.responseStatusTab = resTab.dataset.resTab; - renderStep3Section(); - return; - } - - // 상태코드 삭제 (탭 안의 ✕) — 탭 전환보다 먼저 처리 + // 상태코드 삭제 (탭 안의 ✕) — 탭 전환(data-res-tab / data-ex-res-tab)보다 먼저 처리 const delStatus = target.closest('[data-del-status]'); if (delStatus) { const dcode = delStatus.dataset.delStatus; @@ -1004,6 +1168,14 @@ } 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(); @@ -1026,6 +1198,16 @@ 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) { @@ -1080,8 +1262,11 @@ schedulePreviewSync(); return; } - if (target.id === 'btn-add-param') { - state.data.parameters.push(newParamRow()); + 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; @@ -1091,7 +1276,7 @@ const delSec = target.closest('[data-del-sec]'); if (delSec) { state.data.securitySchemes.splice(Number(delSec.dataset.delSec), 1); - renderStep4($('#form-content')); + renderStep1($('#form-content')); schedulePreviewSync(); return; } @@ -1118,6 +1303,10 @@ 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]'); @@ -1128,6 +1317,15 @@ 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) { @@ -1176,7 +1374,9 @@ form.addEventListener('input', e => { const t = e.target; if (t.dataset.exampleTarget === 'request') { - state.data.examples.request['application/json'].default = t.value; + 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') { @@ -1191,7 +1391,7 @@ } // 포탈 게시 정보 (공개법인 / Mock URL) if (t.dataset.portal === 'displayOrg') { state.data.portal.displayOrg = t.value; } - if (t.dataset.portal === 'mockUrl') { state.data.portal.mockUrl = 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() { @@ -1312,7 +1512,7 @@ if (chosenType === 'openIdConnect') newS.openIdConnectUrl = { value: $('#sec-oidc').value, locked: false }; state.data.securitySchemes.push(newS); modal.classList.add('hidden'); - renderStep4($('#form-content')); + renderStep1($('#form-content')); schedulePreviewSync(); toast(`보안 스킴 "${key}" 추가 완료`); }; @@ -1432,6 +1632,14 @@ var bRegen = $('#btn-regen'); if (bRegen) bRegen.addEventListener('click', regenerate); var bClose = $('#btn-close'); if (bClose) bClose.addEventListener('click', () => window.close()); + // Cmd/Ctrl + S → 저장 (브라우저 기본 저장 대화상자 차단) + document.addEventListener('keydown', function (e) { + if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) { + e.preventDefault(); + saveSpec(false); + } + }); + // iframe(모달)로 열렸을 때만 "새창으로 띄우기" 노출 — 현재 URL 을 새 창으로 열고 모달 닫기 if (window.self !== window.top) { var bNew = $('#btn-newwin'); @@ -1474,7 +1682,8 @@ var d = deepClone(window.SAMPLE_DATA); if (!spec || typeof spec !== 'object') return d; d.__openapi = spec.openapi || '3.1.0'; // openapi 버전 보존(3.0/3.1 모두), 기본 3.1.0 - d.portal = { displayYn: 'Y', roles: { ROLE_USER: false, ROLE_CORP_USER: false, ROLE_CORP_MANAGER: false }, displayOrg: '', orgs: [], responseType: 'sample', mockUrl: '' }; + d.portal = { displayYn: 'Y', roles: { ROLE_USER: false, ROLE_CORP_USER: false, ROLE_CORP_MANAGER: false }, displayOrg: '', orgs: [], apiGroups: [], responseType: 'sample', mockUrl: '' }; + d.msgSpec = { request: '', response: '' }; // 요청/응답 메시지 스펙(HTML, API 소개 페이지용) = apiRequestSpec/apiResponseSpec var info = spec.info || {}; d.info.title = wrap(info.title || '', false); d.info.version = wrap(info.version || '1.0.0', false); @@ -1543,16 +1752,34 @@ } // 스키마 기반 샘플 JSON 생성 (자동생성 예제) - function _primVal(t) { if (t === 'integer' || t === 'number') return 0; if (t === 'boolean') return false; return 'string'; } function _coerce(t, v) { if (t === 'integer') return parseInt(v, 10) || 0; if (t === 'number') return parseFloat(v) || 0; if (t === 'boolean') return v === true || v === 'true'; return v; } + // 기본 예제값: string→sample_명(길이 지정 시 맞춰 자름), integer→123456, number→123456.123, boolean→false + // - 숫자는 길이 지정 시 정수부를 그 길이에 맞춰 자름(실수는 소수 .123 유지) + function _defScalar(t, name, maxLen) { + var ml = parseInt(maxLen, 10); + if (t === 'integer') { + var iv = '123456'; + if (ml && ml > 0 && iv.length > ml) iv = iv.substring(0, ml); + return parseInt(iv, 10); + } + if (t === 'number') { + var ip = '123456'; + if (ml && ml > 0 && ip.length > ml) ip = ip.substring(0, ml); + return parseFloat(ip + '.123'); + } + if (t === 'boolean') return false; + var v = 'sample_' + (name || ''); + if (ml && ml > 0 && v.length > ml) v = v.substring(0, ml); + return v; + } function _sampleVal(row) { var t = row.type.value, ex = row.example && row.example.value; if (t === 'object') return _sampleObj(row.children || []); - if (t === 'array') { var it = (row.itemsType && row.itemsType.value) || 'string'; return [it === 'object' ? _sampleObj(row.children || []) : _primVal(it)]; } + if (t === 'array') { var it = (row.itemsType && row.itemsType.value) || 'string'; return [it === 'object' ? _sampleObj(row.children || []) : _defScalar(it, (row.name && row.name.value) || 'item', '')]; } if (ex !== undefined && ex !== '') return _coerce(t, ex); - return _primVal(t); + return _defScalar(t, (row.name && row.name.value) || '', row.maxLength && row.maxLength.value); } - function _sampleObj(rows) { var o = {}; (rows || []).forEach(function (r) { o[r.name.value] = _sampleVal(r); }); return o; } + function _sampleObj(rows) { var o = {}; (rows || []).forEach(function (r) { if (r && r.name && r.name.value) o[r.name.value] = _sampleVal(r); }); return o; } function ensureExamplesFromSchema(force) { var d = state.data; d.examples = d.examples || { request: {}, response: {}, responseHeaders: [] }; @@ -1568,59 +1795,105 @@ } } - // 예제 재생성 — 자동생성과 동일 규칙(백엔드 GENERATE_SAMPLE = generateSampleDataFromLayout) + // 예제 재생성 — 현재 편집 중(메모리) 스키마 기준 + 자동생성과 동일 값 규칙(D10). 추가/미저장 필드 반영. function regenReqExample() { - var ctx = window.DJB_CTX || {}; - if (!ctx.jsonUrl || !ctx.eaiSvcName) return; - fetch(ctx.jsonUrl + '?cmd=GENERATE_SAMPLE&serviceType=APIGW', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: toForm({ eaiSvcName: ctx.eaiSvcName, layoutType: 'REQUEST' }) }) - .then(r => r.json()) - .then(function (res) { - var mt = (state.data.requestBody && state.data.requestBody.mediaType) || 'application/json'; - state.data.examples.request = state.data.examples.request || {}; - state.data.examples.request[mt] = { default: res.json || '{}' }; - renderForm(); schedulePreviewSync(); - toast('요청 예제 재생성(자동생성 규칙)'); - }) - .catch(e => toast('재생성 실패: ' + e.message, 'error')); + var d = state.data; + var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json'; + d.examples.request = d.examples.request || {}; + d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) }; + renderForm(); schedulePreviewSync(); + toast('요청 예제 재생성(현재 스키마 기준)'); } function regenResExample() { - var ctx = window.DJB_CTX || {}; - if (!ctx.jsonUrl || !ctx.eaiSvcName) return; + var d = state.data; var code = state.responseStatusTab; - fetch(ctx.jsonUrl + '?cmd=GENERATE_SAMPLE&serviceType=APIGW', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: toForm({ eaiSvcName: ctx.eaiSvcName, layoutType: 'RESPONSE' }) }) - .then(r => r.json()) - .then(function (res) { - var set = state.data.examples.response[code] || (state.data.examples.response[code] = { body: '', headers: [] }); - set.body = res.json || '{}'; - renderForm(); schedulePreviewSync(); - toast(code + ' 응답 예제 재생성(자동생성 규칙)'); - }) - .catch(e => toast('재생성 실패: ' + e.message, 'error')); + var r = d.responses[code] || {}; + var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] }); + set.body = JSON.stringify(_sampleObj(r.schema || []), null, 2); + renderForm(); schedulePreviewSync(); + toast(code + ' 응답 예제 재생성(현재 스키마 기준)'); + } + + // 스키마 각 스칼라 필드의 '예제값'(row.example.value) 을 기본 규칙(_defScalar)으로 채움(재귀). + function fillSchemaExamples(rows) { + (rows || []).forEach(function (r) { + if (!r || !r.name) return; + var t = r.type.value; + if (t === 'object' || t === 'array') { + if (r.children && r.children.length) fillSchemaExamples(r.children); + } else { + r.example = r.example || { value: '', locked: false }; + r.example.value = String(_defScalar(t, r.name.value || '', r.maxLength && r.maxLength.value)); + } + }); + } + + // 자동생성(초기화) 시 백엔드가 준 기초 스펙 위에, 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성. + // 재생성 버튼(_sampleObj / schemaToSpecHtml)과 동일 로직 → 자동생성 결과 == 재생성 결과. + function frontAutoGenerate() { + var d = state.data; + var mt = (d.requestBody && d.requestBody.mediaType) || 'application/json'; + // 1) 스키마 필드별 예제값 채움 (요청 + 모든 응답) + fillSchemaExamples(d.requestBody ? d.requestBody.schema : []); + Object.keys(d.responses || {}).forEach(function (code) { fillSchemaExamples((d.responses[code] || {}).schema || []); }); + // 2) 요청/응답 본문 예제 JSON (채워진 예제값 사용) + d.examples = d.examples || { request: {}, response: {} }; + d.examples.request = d.examples.request || {}; + d.examples.request[mt] = { default: JSON.stringify(_sampleObj(d.requestBody ? d.requestBody.schema : []), null, 2) }; + d.examples.response = d.examples.response || {}; + Object.keys(d.responses || {}).forEach(function (code) { + var set = d.examples.response[code] || (d.examples.response[code] = { body: '', headers: [] }); + set.body = JSON.stringify(_sampleObj((d.responses[code] || {}).schema || []), null, 2); + }); + d.msgSpec = d.msgSpec || { request: '', response: '' }; + d.msgSpec.request = schemaToSpecHtml(d.requestBody ? d.requestBody.schema : []); + var r200 = (d.responses && (d.responses['200'] || d.responses[Object.keys(d.responses)[0]])) || {}; + d.msgSpec.response = schemaToSpecHtml(r200.schema || []); } // 공개 법인 선택 팝업(기존 apiSpecManDisplayOrgPopup 재사용) — 자체 iframe 모달. 팝업이 window.parent.selectOrg 호출. + // 공통 iframe 피커 모달(법인/그룹). 팝업이 same-origin 이라 window.parent. 호출 + close shim 으로 닫기. + function openPickerModal(url, title) { + closePickerModal(); + var ov = document.createElement('div'); + ov.id = 'picker-modal'; + ov.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.45);z-index:1000;display:flex;align-items:center;justify-content:center;'; + ov.innerHTML = '
' + + '
' + escapeHtml(title) + '
' + + '
'; + document.body.appendChild(ov); + ov.addEventListener('click', function (e) { if (e.target === ov) closePickerModal(); }); + var x = ov.querySelector('#picker-modal-x'); if (x) x.addEventListener('click', closePickerModal); + var ifr = ov.querySelector('iframe'); + if (ifr) ifr.addEventListener('load', function () { try { ifr.contentWindow.close = function () { closePickerModal(); }; } catch (e) {} }); + } + function closePickerModal() { var ov = document.getElementById('picker-modal'); if (ov && ov.parentNode) ov.parentNode.removeChild(ov); } + function openOrgPopup() { var ctx = window.DJB_CTX || {}; if (!ctx.orgPopupUrl) { toast('법인 팝업을 열 수 없습니다', 'error'); return; } var menuId = new URLSearchParams(location.search).get('menuId') || ''; var ids = (state.data.portal.orgs || []).map(function (o) { return o.id; }).join(','); - var url = ctx.orgPopupUrl + '?cmd=POPUP_LIST&serviceType=APIGW&menuId=' + encodeURIComponent(menuId) + '&selectedOrgIds=' + encodeURIComponent(ids); - var ov = document.createElement('div'); - ov.id = 'org-modal'; - ov.style.cssText = 'position:fixed;inset:0;background:rgba(15,23,42,0.45);z-index:1000;display:flex;align-items:center;justify-content:center;'; - ov.innerHTML = '
' - + '
공개 법인 선택
' - + '
'; - document.body.appendChild(ov); - ov.addEventListener('click', function (e) { if (e.target === ov) closeOrgPopup(); }); - var x = ov.querySelector('#org-modal-x'); if (x) x.addEventListener('click', closeOrgPopup); + openPickerModal(ctx.orgPopupUrl + '?cmd=POPUP_LIST&serviceType=APIGW&menuId=' + encodeURIComponent(menuId) + '&selectedOrgIds=' + encodeURIComponent(ids), '공개 법인 선택'); } - function closeOrgPopup() { var ov = document.getElementById('org-modal'); if (ov && ov.parentNode) ov.parentNode.removeChild(ov); } window.selectOrg = function (orgs) { var p = state.data.portal; p.orgs = orgs || []; p.displayOrg = p.orgs.map(function (o) { return o.id; }).join(','); - closeOrgPopup(); + closePickerModal(); + renderForm(); + }; + + function openGroupPopup() { + var ctx = window.DJB_CTX || {}; + if (!ctx.groupPopupUrl) { toast('그룹 팝업을 열 수 없습니다', 'error'); return; } + var menuId = new URLSearchParams(location.search).get('menuId') || ''; + var ids = (state.data.portal.apiGroups || []).map(function (g) { return g.id; }).join(','); + openPickerModal(ctx.groupPopupUrl + '&serviceType=APIGW&menuId=' + encodeURIComponent(menuId) + '&selectedGroupIds=' + encodeURIComponent(ids), 'API 그룹 선택'); + } + window.selectApiGroup = function (groups) { + state.data.portal.apiGroups = groups || []; + closePickerModal(); renderForm(); }; @@ -1649,6 +1922,13 @@ } if (res.responseType) pp.responseType = res.responseType; if (res.mockUrl) pp.mockUrl = decodeEntities(res.mockUrl); + if (res.apiGroups) { try { pp.apiGroups = JSON.parse(res.apiGroups) || []; } catch (e) { pp.apiGroups = []; } } + // 요청/응답 메시지 스펙(HTML) — 저장본은 저장값, 자동생성분은 프론트에서 생성 + state.data.msgSpec = state.data.msgSpec || { request: '', response: '' }; + if (res.apiRequestSpec) state.data.msgSpec.request = decodeEntities(res.apiRequestSpec); + if (res.apiResponseSpec) state.data.msgSpec.response = decodeEntities(res.apiResponseSpec); + // 저장본이 아니면(자동생성/최초 생성) 추가자료(예제·설명자료)를 프론트에서 스키마 기준 생성 + if ((res.source || '') !== 'saved') frontAutoGenerate(); }) .catch(e => console.error('loadInitialData', e)); } @@ -1671,12 +1951,16 @@ testbedSpec: JSON.stringify(spec), sampleRequest: reqEx, sampleResponse: resEx, + // 요청/응답 메시지 스펙(HTML, API 소개 페이지용) + apiRequestSpec: (d.msgSpec && d.msgSpec.request) || '', + apiResponseSpec: (d.msgSpec && d.msgSpec.response) || '', // 포탈 게시 정보 displayYn: (d.portal && d.portal.displayYn) || 'N', displayRoleCode: d.portal ? Object.keys(d.portal.roles).filter(function (k) { return d.portal.roles[k]; }).join(',') : '', displayOrg: (d.portal && d.portal.displayOrg) || '', responseType: (d.portal && d.portal.responseType) || 'sample', - mockUrl: (d.portal && d.portal.mockUrl) || '' + mockUrl: (d.portal && d.portal.mockUrl) || '', + apiGroupIds: (d.portal && d.portal.apiGroups) ? d.portal.apiGroups.map(function (g) { return g.id; }).join(',') : '' }; fetch(ctx.saveUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: toForm(params) }) .then(r => r.json()) @@ -1701,7 +1985,9 @@ try { state.data = specToData(JSON.parse(res.testbedSpec)); } catch (e) { toast('자동 생성 실패: 스펙 파싱 오류', 'error'); return; } } - // 자동생성 규칙(title/tag/summary=API명, 예제, 서버prepend, opId)은 백엔드(generateSpec)에서 적용됨 → 그대로 로드 + // 구조(title/tag/summary/opId/스키마/보안/헤더)는 백엔드(generateSpec)가 적용한 기초 스펙 그대로 로드. + // 추가자료(예제·설명자료)는 프론트에서 스키마 기준 생성 → 재생성 버튼과 동일 결과. + frontAutoGenerate(); state.step = 1; state.responseStatusTab = state.data.responses && state.data.responses['200'] ? '200' : (state.data.responses ? (Object.keys(state.data.responses)[0] || '200') : '200'); var badge = $('#hdr-source'); if (badge) { badge.textContent = '자동생성'; badge.classList.remove('hidden'); } diff --git a/WebContent/js/djb/apispec/editor-content.css b/WebContent/js/djb/apispec/editor-content.css new file mode 100644 index 0000000..c144110 --- /dev/null +++ b/WebContent/js/djb/apispec/editor-content.css @@ -0,0 +1,334 @@ +/** + * Editor Content Styles (editor-content.css) + * + * Summernote 에디터로 작성된 HTML 콘텐츠의 공통 스타일 + * 관리자포탈/개발자포탈 양쪽에서 동일하게 사용 + * + * 사용법: + * - 관리자포탈: Summernote .note-editable에 .editor-content 클래스 추가 + * - 개발자포탈: 콘텐츠 wrapper에 .editor-content 클래스 추가 + * + * @version 1.0.0 + * @date 2025-12-30 + */ + +/* ========================================================================== + CSS Reset + 기본 설정 + ========================================================================== */ + +.editor-content { + all: revert; + font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important; + font-size: 16px !important; + font-weight: 400 !important; + line-height: 1.8 !important; + color: #1A1A2E !important; + word-wrap: break-word; + text-align: left !important; +} + +/* ========================================================================== + 헤딩 (Headings) + ========================================================================== */ + +.editor-content h1 { + font-size: 20px !important; + font-weight: 700 !important; + margin: 32px 0 16px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +.editor-content h1:first-child { + margin-top: 0 !important; +} + +.editor-content h2 { + font-size: 18px !important; + font-weight: 700 !important; + margin: 24px 0 8px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +.editor-content h3 { + font-size: 16px !important; + font-weight: 600 !important; + margin: 16px 0 8px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +.editor-content h4, +.editor-content h5, +.editor-content h6 { + font-size: 16px !important; + font-weight: 600 !important; + margin: 16px 0 8px !important; + line-height: 1.4 !important; + color: #1A1A2E !important; +} + +/* ========================================================================== + 단락 (Paragraphs) + ========================================================================== */ + +.editor-content p { + margin-bottom: 16px !important; + line-height: 1.8 !important; + font-size: 16px !important; +} + +.editor-content p:last-child { + margin-bottom: 0 !important; +} + +/* ========================================================================== + 리스트 (Lists) - 글로벌 reset 대응 + ========================================================================== */ + +.editor-content ul { + list-style-type: disc !important; + padding-left: 32px !important; + margin: 16px 0 !important; +} + +.editor-content ol { + list-style-type: decimal !important; + padding-left: 32px !important; + margin: 16px 0 !important; +} + +.editor-content li { + margin-bottom: 8px !important; + line-height: 1.8 !important; + font-size: 16px !important; +} + +.editor-content li:last-child { + margin-bottom: 0 !important; +} + +/* 중첩 리스트 */ +.editor-content ul ul { + list-style-type: circle !important; + margin: 8px 0 !important; +} + +.editor-content ul ul ul { + list-style-type: square !important; +} + +.editor-content ol ol { + list-style-type: lower-alpha !important; + margin: 8px 0 !important; +} + +.editor-content ol ol ol { + list-style-type: lower-roman !important; +} + +/* ========================================================================== + 테이블 (Tables) + ========================================================================== */ + +.editor-content table { + width: 100% !important; + border-collapse: collapse !important; + margin: 24px 0 !important; + border: 1px solid #E2E8F0 !important; + border-radius: 8px !important; + overflow: hidden !important; + font-size: 14px !important; +} + +.editor-content th, +.editor-content td { + border: 1px solid #E2E8F0 !important; + padding: 8px 16px !important; + text-align: left !important; + vertical-align: top !important; +} + +.editor-content th { + background: #EFF6FF !important; + font-weight: 600 !important; + color: #1A1A2E !important; +} + +.editor-content tr:hover { + background: #F8FAFC !important; +} + +/* ========================================================================== + 링크 (Links) + ========================================================================== */ + +.editor-content a { + color: #0049b4 !important; + text-decoration: underline !important; + transition: color 0.3s ease !important; +} + +.editor-content a:hover { + color: #003080 !important; +} + +/* ========================================================================== + 인용문 (Blockquote) + ========================================================================== */ + +.editor-content blockquote { + border-left: 4px solid #0049b4 !important; + padding: 16px 24px !important; + margin: 24px 0 !important; + background: #F8FAFC !important; + font-style: italic !important; + color: #64748B !important; + border-radius: 0 8px 8px 0 !important; +} + +.editor-content blockquote p { + margin-bottom: 0 !important; +} + +/* ========================================================================== + 이미지 (Images) + ========================================================================== */ + +.editor-content img { + max-width: 100% !important; + height: auto !important; + border-radius: 8px !important; + margin: 16px 0 !important; + display: block !important; +} + +/* ========================================================================== + 코드 (Code) + ========================================================================== */ + +.editor-content code { + background: #F8FAFC !important; + padding: 2px 6px !important; + border-radius: 4px !important; + font-family: 'Fira Code', 'Courier New', monospace !important; + font-size: 0.9em !important; + color: #0049b4 !important; +} + +.editor-content pre { + background: #F8FAFC !important; + border: 1px solid #E2E8F0 !important; + border-radius: 8px !important; + padding: 16px !important; + overflow-x: auto !important; + margin: 16px 0 !important; +} + +.editor-content pre code { + background: transparent !important; + padding: 0 !important; + color: #1A1A2E !important; +} + +/* ========================================================================== + 구분선 (Horizontal Rule) + ========================================================================== */ + +.editor-content hr { + border: none !important; + border-top: 1px solid #E2E8F0 !important; + margin: 32px 0 !important; +} + +/* ========================================================================== + 텍스트 강조 (Text Emphasis) + ========================================================================== */ + +.editor-content strong, +.editor-content b { + font-weight: 700 !important; +} + +.editor-content em, +.editor-content i { + font-style: italic !important; +} + +.editor-content u { + text-decoration: underline !important; +} + +.editor-content s, +.editor-content strike, +.editor-content del { + text-decoration: line-through !important; +} + +.editor-content mark { + background-color: #FEF3C7 !important; + padding: 0 2px !important; +} + +.editor-content sub { + vertical-align: sub !important; + font-size: 0.8em !important; +} + +.editor-content sup { + vertical-align: super !important; + font-size: 0.8em !important; +} + +/* ========================================================================== + 반응형 (Responsive) + ========================================================================== */ + +@media (max-width: 768px) { + .editor-content { + font-size: 14px !important; + } + + .editor-content h1 { + font-size: 18px !important; + } + + .editor-content h2 { + font-size: 16px !important; + } + + .editor-content h3, + .editor-content h4, + .editor-content h5, + .editor-content h6 { + font-size: 14px !important; + } + + .editor-content p, + .editor-content li { + font-size: 14px !important; + } + + .editor-content table { + font-size: 12px !important; + } + + .editor-content th, + .editor-content td { + padding: 4px 8px !important; + } + + .editor-content ul, + .editor-content ol { + padding-left: 24px !important; + } + + .editor-content blockquote { + padding: 8px 16px !important; + } + + .editor-content pre { + padding: 8px !important; + } +} diff --git a/WebContent/jsp/onl/apim/apigroup/apiGroupManDetail.jsp b/WebContent/jsp/onl/apim/apigroup/apiGroupManDetail.jsp index a4a507f..7bd7487 100644 --- a/WebContent/jsp/onl/apim/apigroup/apiGroupManDetail.jsp +++ b/WebContent/jsp/onl/apim/apigroup/apiGroupManDetail.jsp @@ -23,7 +23,7 @@ + + + + + diff --git a/WebContent/jsp/onl/transaction/apim/djbApiSpecManPopup.jsp b/WebContent/jsp/onl/transaction/apim/djbApiSpecManPopup.jsp index 0367229..63db71b 100644 --- a/WebContent/jsp/onl/transaction/apim/djbApiSpecManPopup.jsp +++ b/WebContent/jsp/onl/transaction/apim/djbApiSpecManPopup.jsp @@ -10,7 +10,7 @@ - DJB API 문서 에디터 — OpenAPI Editor + OpenAPI Spec 에디터 <%-- Tailwind (벤더된 Play CDN JS, 오프라인) --%> @@ -33,9 +33,12 @@ + <%-- jQuery + Summernote(lite, 무-Bootstrap) — 설명 리치 에디터 --%> + <%-- 개발자포탈과 동일한 에디터 콘텐츠 스타일(.editor-content, Noto Sans KR 16px) --%> + @@ -64,7 +67,8 @@ saveUrl: "?cmd=SAVE&serviceType=APIGW", swaggerBase: "", gwAddress: "${gwAddress}", - orgPopupUrl: "", + orgPopupUrl: "", + groupPopupUrl: "?cmd=GROUP_POPUP", jsonUrl: "" }; @@ -103,7 +107,7 @@
-
    +
      @@ -111,7 +115,7 @@
      - Step 1 / 6 + Step 1 / 5

      기본정보

      diff --git a/build.gradle b/build.gradle index 81b2fd6..7f90ea0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,9 @@ plugins { - id 'java-library' + id 'java-library' id 'eclipse' - id 'eclipse-wtp' + id 'eclipse-wtp' id 'idea' id 'war' - id 'com.diffplug.eclipse.apt' version '3.41.1' } group 'com.eactive' @@ -32,11 +31,11 @@ allprojects { project.webAppDirName = 'WebContent' java { - toolchain { - languageVersion = JavaLanguageVersion.of(8) - } + toolchain { + languageVersion = JavaLanguageVersion.of(8) + } } - + sourceSets { main { java { @@ -46,56 +45,56 @@ sourceSets { } compileJava { - options.encoding = 'UTF-8' - options.compilerArgs = ['-parameters'] - options.generatedSourceOutputDirectory = project.file(generatedJavaDir) - - aptOptions { - processorArgs = [ 'querydsl.generatedAnnotationClass' : 'com.querydsl.core.annotations.Generated' ] - } + options.encoding = 'UTF-8' + options.compilerArgs = ['-parameters'] + options.generatedSourceOutputDirectory = project.file(generatedJavaDir) } war { - def profile = project.findProperty("profile") ?: "tomcat" - - if (profile == "weblogic") { - webXml = file("WebContent/WEB-INF/weblogic-web.xml") - exclude 'WEB-INF/web.xml' - } - - - // rootSpec.exclude '**/persistence.xml' - exclude '**/context.xml' - exclude '**/context-*.xml' - archiveFileName = "eapim-admin.war" - duplicatesStrategy = DuplicatesStrategy.WARN + def profile = project.findProperty("profile") ?: "tomcat" + + if (profile == "weblogic") { + webXml = file("WebContent/WEB-INF/weblogic-web.xml")ㅇ + exclude 'WEB-INF/web.xml' + } + + + // rootSpec.exclude '**/persistence.xml' + exclude '**/context.xml' + exclude '**/context-*.xml' + archiveFileName = "eapim-admin.war" + duplicatesStrategy = DuplicatesStrategy.WARN } tasks.withType(JavaCompile) { - options.encoding = 'UTF-8' - options.compilerArgs += ['-parameters', "-Xlint:unchecked", "-Xlint:deprecation"] + options.encoding = 'UTF-8' + options.compilerArgs += ['-parameters', "-Xlint:unchecked", "-Xlint:deprecation"] } dependencies { - - annotationProcessor "org.projectlombok:lombok:1.18.28", "org.projectlombok:lombok-mapstruct-binding:0.2.0", "org.mapstruct:mapstruct-processor:1.5.5.Final" - annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jpa", "jakarta.persistence:jakarta.persistence-api:2.2.3", "jakarta.annotation:jakarta.annotation-api:1.3.5" - + + annotationProcessor "org.projectlombok:lombok:1.18.28", + "org.projectlombok:lombok-mapstruct-binding:0.2.0", + "org.mapstruct:mapstruct-processor:1.5.5.Final" + annotationProcessor "com.querydsl:querydsl-apt:${queryDslVersion}:jpa", + "jakarta.persistence:jakarta.persistence-api:2.2.3", + "jakarta.annotation:jakarta.annotation-api:1.3.5" + implementation project(':elink-online-core') implementation project(':elink-online-transformer') implementation project(':elink-online-common') - implementation project(':elink-online-emsclient') - implementation project(':elink-portal-common') - //implementation project(':kjb-safedb') + implementation project(':elink-online-emsclient') + implementation project(':elink-portal-common') + //implementation project(':kjb-safedb') implementation project(":eapim-admin-djb") /* Custom Libs (damo-manager.jar 는 Tomcat lib 가 런타임 제공 → compileOnly, WAR 미포함) */ implementation fileTree(dir: 'libs', include: ['*.jar'], exclude: ['damo-manager.jar']) compileOnly files('libs/damo-manager.jar') - //implementation "org.dom4j:dom4j:2.1.3" + //implementation "org.dom4j:dom4j:2.1.3" //implementation "com.rabbitmq:amqp-client:3.6.6" implementation "commons-primitives:commons-primitives:1.0" @@ -117,10 +116,10 @@ dependencies { // https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api compileOnly group: 'javax.servlet', name: 'javax.servlet-api', version: '4.0.1' - + implementation group: 'com.fasterxml.jackson.core', name: 'jackson-databind', version: '2.13.1' //implementation group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.13.1' implementation "javax.jms:javax.jms-api:2.0" implementation "org.snmp4j:snmp4j:1.10.1" @@ -187,32 +186,32 @@ dependencies { implementation 'ch.qos.logback:logback-classic:1.2.10' implementation 'ch.qos.logback:logback-access:1.2.10' implementation 'ch.qos.logback:logback-core:1.2.10' - - implementation files("libs/eai-bap-client.jar") - - compileOnly 'javax.xml.bind:jaxb-api:2.3.1' - - //implementation 'org.postgresql:postgresql:42.2.23' - - implementation 'backport-util-concurrent:backport-util-concurrent:3.0' - - implementation ('software.amazon.awssdk:s3:2.20.142') { - exclude group: 'org.slf4j', module: 'slf4j-api' - exclude group: 'org.slf4j', module: 'logback-classic' - } - implementation 'software.amazon.awssdk:sso:2.20.142' - implementation 'software.amazon.awssdk:sts:2.20.142' - implementation group: 'commons-net', name: 'commons-net', version: '3.5' + implementation files("libs/eai-bap-client.jar") - // JDK 8 의 rt.jar 에는 org.w3c.dom.ElementTraversal 이 없어 xercesImpl 가 NCDFE 를 일으킴. - // xml-apis 1.4.01 에 그 클래스가 포함됨. 직접 의존성으로 묶어 WAR 에 패키징되도록 함. - implementation 'xml-apis:xml-apis:1.4.01' + compileOnly 'javax.xml.bind:jaxb-api:2.3.1' - testRuntimeOnly 'com.h2database:h2:2.1.214' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' - testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.15' + //implementation 'org.postgresql:postgresql:42.2.23' + + implementation 'backport-util-concurrent:backport-util-concurrent:3.0' + + implementation ('software.amazon.awssdk:s3:2.20.142') { + exclude group: 'org.slf4j', module: 'slf4j-api' + exclude group: 'org.slf4j', module: 'logback-classic' + } + implementation 'software.amazon.awssdk:sso:2.20.142' + implementation 'software.amazon.awssdk:sts:2.20.142' + + implementation group: 'commons-net', name: 'commons-net', version: '3.5' + + // JDK 8 의 rt.jar 에는 org.w3c.dom.ElementTraversal 이 없어 xercesImpl 가 NCDFE 를 일으킴. + // xml-apis 1.4.01 에 그 클래스가 포함됨. 직접 의존성으로 묶어 WAR 에 패키징되도록 함. + implementation 'xml-apis:xml-apis:1.4.01' + + testRuntimeOnly 'com.h2database:h2:2.1.214' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' + testImplementation 'org.springframework.boot:spring-boot-starter-test:2.6.15' } test { @@ -220,9 +219,9 @@ test { } configurations.all { - exclude group: 'log4j', module: 'log4j' - exclude group: 'org.apache.activemq' - exclude group: 'org.codehaus.jackson' + exclude group: 'log4j', module: 'log4j' + exclude group: 'org.apache.activemq' + exclude group: 'org.codehaus.jackson' resolutionStrategy { // 10 minute cache of dynamic version navigation cacheDynamicVersionsFor 10, 'minutes' @@ -246,29 +245,25 @@ task settingEclipseEncoding { } task initDirs() { - file(generatedJavaDir).mkdirs() + file(generatedJavaDir).mkdirs() } eclipse { - wtp { - component { - contextPath = 'monitoring' - } - } - synchronizationTasks settingEclipseEncoding, initDirs - jdt { - apt { - // generated 된 패스 경로를 .setting/org.eclipse.jdt.apt.core.prefs 의 org.eclipse.jdt.apt.genSrcDir에 적용한다. - // project > properties > Java Compiler > Annoation Processing 화면에서 확인 가능하다. - genSrcDir = file(generatedJavaDir) - } - } - project { - if (!natures.contains('org.eclipse.buildship.core.gradleprojectnature')) { - natures.add('org.eclipse.buildship.core.gradleprojectnature') - buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' - } - } + wtp { + component { + contextPath = 'monitoring' + } + } + synchronizationTasks settingEclipseEncoding, initDirs + jdt { + + } + project { + if (!natures.contains('org.eclipse.buildship.core.gradleprojectnature')) { + natures.add('org.eclipse.buildship.core.gradleprojectnature') + buildCommand 'org.eclipse.buildship.core.gradleprojectbuilder' + } + } } tasks.withType(JavaCompile) { diff --git a/build.gradle.intellij b/build.gradle.intellij index eaa9f52..1e824cf 100644 --- a/build.gradle.intellij +++ b/build.gradle.intellij @@ -87,8 +87,8 @@ dependencies { implementation project(":eapim-admin-djb") /* Custom Libs (damo-manager.jar 는 Tomcat lib 가 런타임 제공 → compileOnly, WAR 미포함) */ - implementation fileTree(dir: 'libs', include: ['*.jar'], exclude: ['damo-manager.jar']) - compileOnly files('libs/damo-manager.jar') + implementation fileTree(dir: 'libs', include: ['*.jar'], exclude: ['damo-manager.jar']) + compileOnly files('libs/damo-manager.jar') //implementation "org.dom4j:dom4j:2.1.3" //implementation "com.rabbitmq:amqp-client:3.6.6" diff --git a/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/apigroup/ApiGroupService.java b/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/apigroup/ApiGroupService.java index 85bf102..75b8b80 100644 --- a/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/apigroup/ApiGroupService.java +++ b/src/main/java/com/eactive/eai/rms/data/entity/onl/apim/apigroup/ApiGroupService.java @@ -2,6 +2,7 @@ package com.eactive.eai.rms.data.entity.onl.apim.apigroup; import com.eactive.eai.data.entity.onl.apim.apigroup.ApiGroup; import com.eactive.eai.data.entity.onl.apim.apigroup.ApiGroupApi; +import com.eactive.eai.data.entity.onl.apim.apigroup.ApiGroupApiId; import com.eactive.eai.data.entity.onl.apim.apigroup.QApiGroup; import com.eactive.eai.data.entity.onl.apim.apigroup.QApiGroupApi; import com.eactive.eai.data.jpa.AbstractDataService; @@ -75,4 +76,37 @@ public class ApiGroupService extends AbstractDataService groupIds) { + deleteApiGroupApiByApiId(apiId); + if (groupIds == null) { + return; + } + for (String gid : groupIds) { + if (StringUtils.isBlank(gid)) { + continue; + } + ApiGroup group = repository.findById(gid).orElse(null); + if (group == null) { + continue; + } + Hibernate.initialize(group.getApiGroupApiList()); + List list = group.getApiGroupApiList(); + boolean exists = false; + for (ApiGroupApi a : list) { + if (a.getId() != null && apiId.equals(a.getId().getApiId())) { + exists = true; + break; + } + } + if (!exists) { + ApiGroupApi a = new ApiGroupApi(); + a.setId(new ApiGroupApiId(gid, apiId)); + a.setDisplayOrder(list.size() + 1); + list.add(a); + repository.save(group); + } + } + } } diff --git a/src/main/java/com/eactive/eai/rms/onl/apim/portalproperty/PortalPropertyMapper.java b/src/main/java/com/eactive/eai/rms/onl/apim/portalproperty/PortalPropertyMapper.java index cbadeed..5eecf96 100644 --- a/src/main/java/com/eactive/eai/rms/onl/apim/portalproperty/PortalPropertyMapper.java +++ b/src/main/java/com/eactive/eai/rms/onl/apim/portalproperty/PortalPropertyMapper.java @@ -14,6 +14,7 @@ public interface PortalPropertyMapper extends GenericMapper> save(ApiSpecInfoUI apiSpecInfoUI) { + public ResponseEntity> save(ApiSpecInfoUI apiSpecInfoUI, String apiGroupIds) { Map result = new HashMap<>(); if (apiSpecInfoUI == null || StringUtils.isBlank(apiSpecInfoUI.getApiId())) { result.put("status", "fail"); @@ -153,6 +188,21 @@ public class DjbApiSpecController extends OnlBaseAnnotationController { apiSpecManService.save(apiSpecInfoUI); + // API 그룹 소속(조인테이블) 동기화 — apiGroupIds(CSV) 로 재설정 + try { + java.util.List gids = new ArrayList<>(); + if (StringUtils.isNotBlank(apiGroupIds)) { + for (String g : apiGroupIds.split(",")) { + if (StringUtils.isNotBlank(g)) { + gids.add(g.trim()); + } + } + } + apiGroupService.setApiGroupsForApi(apiSpecInfoUI.getApiId(), gids); + } catch (Exception e) { + logger.warn("API 그룹 동기화 실패: " + apiSpecInfoUI.getApiId(), e); + } + result.put("status", "success"); result.put("apiId", apiSpecInfoUI.getApiId()); return ResponseEntity.ok(result); @@ -272,7 +322,15 @@ public class DjbApiSpecController extends OnlBaseAnnotationController { tags.add(tag); root.set("tags", tags); - // servers 는 유지(상대 path). 최종 서버 base 는 프론트에서 응답유형(gw/mock/기본)에 따라 선택. + // 어댑터경로(servers[0].url = getHttpAdapterInfo urlPath)를 path 앞에 baking 하고 servers 제거. + // 호스트(gw=djb.gateway.base-url / mock=Mock URL / sample=없음)는 프론트가 응답유형에 따라 서버로 붙인다. + String adapterPath = ""; + JsonNode serversNode = root.get("servers"); + if (serversNode != null && serversNode.isArray() && serversNode.size() > 0 && serversNode.get(0).get("url") != null) { + adapterPath = serversNode.get(0).get("url").asText(""); + } + root.remove("servers"); + JsonNode pathsNode = root.get("paths"); if (pathsNode != null && pathsNode.isObject()) { ObjectNode paths = (ObjectNode) pathsNode; @@ -298,13 +356,18 @@ public class DjbApiSpecController extends OnlBaseAnnotationController { ArrayNode opTags = objectMapper.createArrayNode(); opTags.add(tagName); op.set("tags", opTags); - setContentExample(op, reqSample); - setResponseExample(op, "200", resSample); + // 예제(요청/응답 본문)는 프론트에서 스키마 기준으로 생성 → 자동생성/재생성 결과 일치. 여기선 임베드하지 않음. markGwOnOperation(op); addResponseHeader(op, "200", "Content-Type", contentType); } } } + String newKey = adapterPath.isEmpty() ? pk + : (adapterPath.replaceAll("/+$", "") + (pk.startsWith("/") ? "" : "/") + pk); + if (!newKey.equals(pk)) { + paths.set(newKey, pathItem); + paths.remove(pk); + } } } return objectMapper.writeValueAsString(root);