- JSP: OpenAPI Spec 모달 처리 개선 - 동적 크기 조절 및 스타일 추가

- Service: GET/DELETE 요청 바디 항목→쿼리 파라미터 매핑 로직 추가
- JS: 요청 정보 진입 시 탭 자동 전환 로직(firstReqTabWithData) 구현
This commit is contained in:
Rinjae
2026-07-13 15:30:08 +09:00
parent 4530af9dec
commit 8ccf52fd66
5 changed files with 106 additions and 286 deletions
+14 -1
View File
@@ -301,7 +301,7 @@
function goStep(n) { function goStep(n) {
if (n < 1 || n > STEPS.length) return; if (n < 1 || n > STEPS.length) return;
state.step = n; state.step = n;
if (n === 2) state.reqInfoTab = 'body'; // 요청/응답 정보: 진입 시 항상 Body 먼저 if (n === 2) state.reqInfoTab = firstReqTabWithData(); // 요청 정보: 값 있는 탭(Header→Parameter→Body 순) 먼저
if (n === 3) state.resInfoTab = 'body'; if (n === 3) state.resInfoTab = 'body';
renderStepper(); renderStepper();
renderForm(); renderForm();
@@ -439,6 +439,19 @@
+ '</div>'; + '</div>';
} }
// 요청 정보 진입 시 먼저 보일 서브탭 = 값이 든 첫 탭(Header→Parameter→Body 순).
// 보통 GET 은 Parameter(query), POST 는 Body 에만 값이 있음. 전부 비면 Body 로.
function firstReqTabWithData() {
var params = (state.data && state.data.parameters) || [];
var hasHeader = params.some(function (p) { return p.in === 'header'; });
var hasParam = params.some(function (p) { return p.in === 'query'; });
var hasBody = !!(state.data && state.data.requestBody && state.data.requestBody.schema && state.data.requestBody.schema.length);
if (hasHeader) return 'header';
if (hasParam) return 'param';
if (hasBody) return 'body';
return 'body';
}
// Step 2 — 요청 정보 (Header / Parameter / Body / API 설명(HTML)) // Step 2 — 요청 정보 (Header / Parameter / Body / API 설명(HTML))
function renderReqInfo(root) { function renderReqInfo(root) {
const tab = state.reqInfoTab || 'body'; const tab = state.reqInfoTab || 'body';
+33 -268
View File
@@ -1,298 +1,63 @@
// 계좌이체 API 모의 데이터 // OpenAPI 에디터 초기 상태 골격(빈 껍데기).
// locked: true 인 필드는 게이트웨이가 자동 주입한 값(회색 + 자물쇠) // - specToData() 가 deepClone 하여 서버 spec 값으로 덮어쓰는 구조 기준값.
// locked: false / 미지정 인 필드는 사용자가 자유롭게 편집 // - 실제 데모/샘플 값은 두지 않는다(팝업은 항상 서버 spec 로드 후 렌더).
// - docOptions 만 기능 기본값으로 유지(문서 미리보기 옵션).
// locked:true = 게이트웨이 자동 주입(회색+자물쇠) / locked:false = 사용자 편집.
window.SAMPLE_DATA = (function () { window.SAMPLE_DATA = (function () {
function f() { return { value: '', locked: false }; }
return { return {
info: { info: {
title: { value: '계좌이체 API', locked: false }, title: f(),
version: { value: '1.0.0', locked: false }, version: f(),
summary: { value: '', locked: false }, summary: f(),
description: { value: '', locked: false }, description: f(),
termsOfService: { value: '', locked: false }, termsOfService: f(),
contact: { contact: { name: f(), email: f(), url: f() },
name: { value: 'DJB API Team', locked: false }, license: { name: f(), url: f() }
email: { value: 'api@djb.co.kr', locked: false },
url: { value: '', locked: false }
},
license: {
name: { value: '', locked: false },
url: { value: '', locked: false }
}
}, },
tags: [ tags: [],
{ name: 'transfer', description: '계좌이체 관련 API' }
],
externalDocs: { externalDocs: { description: f(), url: f() },
description: { value: '', locked: false },
url: { value: '', locked: false }
},
servers: [ servers: [],
{
url: { value: 'https://api-dev.djb.co.kr', locked: true },
env: 'development',
description: { value: '개발 환경', locked: false }
},
{
url: { value: 'https://api-stg.djb.co.kr', locked: true },
env: 'staging',
description: { value: '스테이징 환경', locked: false }
},
{
url: { value: 'https://api.djb.co.kr', locked: true },
env: 'production',
description: { value: '운영 환경', locked: false }
}
],
serverVariables: [ serverVariables: [],
{
name: { value: 'basePath', locked: true },
default: { value: '/v1', locked: false },
enumStr: { value: '/v1,/v2', locked: false },
description: { value: 'API 버전 경로', locked: false }
}
],
operation: { operation: {
method: { value: 'POST', locked: true }, method: f(),
path: { value: '/v1/accounts/transfer', locked: true }, path: f(),
operationId: { value: 'transferAccount', locked: false }, operationId: f(),
summary: { value: '', locked: false }, summary: f(),
description: { value: '', locked: false }, description: f(),
tags: { value: ['transfer'], locked: false }, tags: { value: [], locked: false },
deprecated: { value: false, locked: false } deprecated: { value: false, locked: false }
}, },
// 파라미터 (Path/Query/Header/Cookie) // 파라미터 (Path/Query/Header/Cookie)
parameters: [ parameters: [],
{
in: 'header',
name: { value: 'X-Request-Id', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: 'uuid', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '', locked: false }
}
],
// Request Body 스키마 (중첩 3단) // Request Body 스키마
requestBody: { requestBody: {
mediaType: 'application/json', mediaType: 'application/json',
required: { value: true, locked: true }, required: { value: false, locked: false },
schema: [ schema: []
{
name: { value: 'transactionId', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '40', locked: false },
pattern: { value: '^tx-[0-9a-zA-Z-]+$', locked: false },
description: { value: '', locked: false },
example: { value: 'tx-20260522-001', locked: false },
children: null
},
{
name: { value: 'sender', locked: true },
type: { value: 'object', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '송금인 정보', locked: false },
example: { value: '', locked: false },
children: [
{
name: { value: 'accountNumber', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '20', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '110-1234-567890', locked: false },
children: null
},
{
name: { value: 'bankCode', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '3', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '088', locked: false },
children: null
},
{
name: { value: 'holderName', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '60', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '김철수', locked: false },
children: null
}
]
},
{
name: { value: 'receiver', locked: true },
type: { value: 'object', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '수취인 정보', locked: false },
example: { value: '', locked: false },
children: [
{
name: { value: 'accountNumber', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '20', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '111-2345-678901', locked: false },
children: null
},
{
name: { value: 'bankCode', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '3', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '020', locked: false },
children: null
},
{
name: { value: 'holderName', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '60', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '이영희', locked: false },
children: null
}
]
},
{
name: { value: 'amount', locked: true },
type: { value: 'object', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '이체 금액', locked: false },
example: { value: '', locked: false },
children: [
{
name: { value: 'value', locked: true },
type: { value: 'integer', locked: true },
required: { value: true, locked: true },
format: { value: 'int64', locked: false },
maxLength: { value: '', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '50000', locked: false },
children: null
},
{
name: { value: 'currency', locked: true },
type: { value: 'string', locked: true },
required: { value: true, locked: true },
format: { value: '', locked: false },
maxLength: { value: '3', locked: false },
pattern: { value: '^[A-Z]{3}$', locked: false },
description: { value: 'ISO 4217 통화코드', locked: false },
example: { value: 'KRW', locked: false },
children: null
}
]
},
{
name: { value: 'memo', locked: true },
type: { value: 'string', locked: true },
required: { value: false, locked: true },
format: { value: '', locked: false },
maxLength: { value: '100', locked: false },
pattern: { value: '', locked: false },
description: { value: '', locked: false },
example: { value: '5월 회비', locked: false },
children: null
}
]
}, },
// 응답 스키마 (상태코드 별) // 응답 스키마 (상태코드 별)
responses: { responses: {
'200': { '200': { description: f(), schema: [] }
description: { value: '이체 성공', locked: false },
schema: [
{ name: { value: 'transactionId', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: 'tx-20260522-001', locked: false }, children: null },
{ name: { value: 'status', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: 'COMPLETED', locked: false }, children: null },
{ name: { value: 'completedAt', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: 'date-time', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '2026-05-22T10:00:00Z', locked: false }, children: null },
{
name: { value: 'balance', locked: true }, type: { value: 'object', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '잔액 정보', locked: false }, example: { value: '', locked: false },
children: [
{ name: { value: 'before', locked: true }, type: { value: 'integer', locked: true }, required: { value: true, locked: true }, format: { value: 'int64', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '1000000', locked: false }, children: null },
{ name: { value: 'after', locked: true }, type: { value: 'integer', locked: true }, required: { value: true, locked: true }, format: { value: 'int64', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '950000', locked: false }, children: null }
]
}
]
},
'400': {
description: { value: '잘못된 요청', locked: false },
schema: [
{
name: { value: 'error', locked: true }, type: { value: 'object', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '', locked: false },
children: [
{ name: { value: 'code', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: 'INVALID_AMOUNT', locked: false }, children: null },
{ name: { value: 'message', locked: true }, type: { value: 'string', locked: true }, required: { value: true, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '금액이 올바르지 않습니다', locked: false }, children: null },
{ name: { value: 'details', locked: true }, type: { value: 'array', locked: true }, required: { value: false, locked: true }, format: { value: '', locked: false }, maxLength: { value: '', locked: false }, pattern: { value: '', locked: false }, description: { value: '', locked: false }, example: { value: '', locked: false }, itemsType: { value: 'string', locked: false }, children: null }
]
}
]
},
'401': { description: { value: '인증 실패', locked: false }, schema: [] },
'500': { description: { value: '서버 오류', locked: false }, schema: [] }
}, },
// 보안 스킴 // 보안 스킴
securitySchemes: [ securitySchemes: [],
{ globalSecurity: [],
key: 'ApiKeyAuth',
type: 'apiKey',
locked: true,
name: { value: 'X-API-Key', locked: true },
in: { value: 'header', locked: true },
description: { value: '', locked: false }
}
],
globalSecurity: ['ApiKeyAuth'],
// 예제 // 예제
examples: { examples: {
request: { request: {},
'application/json': { response: {}
default: '{\n "transactionId": "tx-20260522-001",\n "sender": {\n "accountNumber": "110-1234-567890",\n "bankCode": "088",\n "holderName": "김철수"\n },\n "receiver": {\n "accountNumber": "111-2345-678901",\n "bankCode": "020",\n "holderName": "이영희"\n },\n "amount": {\n "value": 50000,\n "currency": "KRW"\n },\n "memo": "5월 회비"\n}'
}
},
response: {
'200': { body: '{\n "transactionId": "tx-20260522-001",\n "status": "COMPLETED"\n}', headers: [ { name: 'X-RateLimit-Remaining', type: 'integer', description: '남은 요청 횟수', example: '99' } ] }
}
}, },
// 문서 옵션 // 문서 옵션
@@ -78,6 +78,9 @@
vertical-align: middle; vertical-align: middle;
} }
/* iframe 모달(showModal) 콘텐츠 패딩 제거 — iframe 이 다이얼로그 폭을 최대한 사용해 내부 가로스크롤 방지 */
.ui-dialog .ui-dialog-content { padding: 0 !important; }
</style> </style>
<script language="javascript" > <script language="javascript" >
var isDetail = false; var isDetail = false;
@@ -1012,7 +1015,12 @@
window.open(urlAddServiceType(baseUrl + '&menuId=' + encodeURIComponent(getMenuId())), '_blank'); window.open(urlAddServiceType(baseUrl + '&menuId=' + encodeURIComponent(getMenuId())), '_blank');
} else { } else {
// 기본: 모달 + iframe. showModal 이 serviceType/menuId/pop 을 자동 부착. // 기본: 모달 + iframe. showModal 이 serviceType/menuId/pop 을 자동 부착.
showModal(baseUrl, { title: 'OpenAPI Spec' }, 1600, 880); // 콘텐츠 고정폭 1600 + 다이얼로그 chrome(패딩/보더 ~40px) 때문에 iframe 내부폭이 1600 미만이면 가로스크롤 발생.
// 뷰포트 내에서 최대한 크게 열어(캡 1720) iframe 내부폭이 1600 이상 확보되게 한다.
var vw = $(window).width(), vh = $(window).height();
var modalW = Math.min(vw - 12, 1720);
var modalH = Math.min(vh - 20, 900);
showModal(baseUrl, { title: 'OpenAPI Spec' }, modalW, modalH);
} }
} }
@@ -1537,7 +1545,7 @@
<button type="button" class="cssbtn" id="btn_delete" level="W" status="DETAIL"><i class="material-icons">delete</i> <%= localeMessage.getString("button.delete") %></button> <button type="button" class="cssbtn" id="btn_delete" level="W" status="DETAIL"><i class="material-icons">delete</i> <%= localeMessage.getString("button.delete") %></button>
<button type="button" class="cssbtn" id="btn_modify" level="R" status="DETAIL,NEW"><i class="material-icons">save</i> <%= localeMessage.getString("button.modify") %></button> <button type="button" class="cssbtn" id="btn_modify" level="R" status="DETAIL,NEW"><i class="material-icons">save</i> <%= localeMessage.getString("button.modify") %></button>
<!-- v1: 기존 API 스펙 (모달) — 기능 비교용 복원 --> <!-- v1: 기존 API 스펙 (모달) — 기능 비교용 복원 -->
<button type="button" class="cssbtn" id="btn_api_spec" level="R" status="DETAIL,NEW"><i class="material-icons">description</i> API 스펙</button> <button type="button" class="cssbtn" id="btn_api_spec" level="R" status="DETAIL,NEW"><i class="material-icons">description</i> API 스펙(삭제예정)</button>
<!-- v2: 신규 OpenAPI Spec (새 탭) --> <!-- v2: 신규 OpenAPI Spec (새 탭) -->
<button type="button" class="cssbtn" id="btn_openapi_spec" level="R" status="DETAIL,NEW"><i class="material-icons">api</i> OpenAPI Spec</button> <button type="button" class="cssbtn" id="btn_openapi_spec" level="R" status="DETAIL,NEW"><i class="material-icons">api</i> OpenAPI Spec</button>
<button type="button" class="cssbtn" id="btn_previous" level="R" status="DETAIL,NEW"><i class="material-icons">arrow_back</i> <%= localeMessage.getString("button.previous") %></button> <button type="button" class="cssbtn" id="btn_previous" level="R" status="DETAIL,NEW"><i class="material-icons">arrow_back</i> <%= localeMessage.getString("button.previous") %></button>
+5 -10
View File
@@ -4,7 +4,6 @@ plugins {
id 'eclipse-wtp' id 'eclipse-wtp'
id 'idea' id 'idea'
id 'war' id 'war'
id 'com.diffplug.eclipse.apt' version '3.41.1'
} }
group 'com.eactive' group 'com.eactive'
@@ -49,10 +48,6 @@ compileJava {
options.encoding = 'UTF-8' options.encoding = 'UTF-8'
options.compilerArgs = ['-parameters'] options.compilerArgs = ['-parameters']
options.generatedSourceOutputDirectory = project.file(generatedJavaDir) options.generatedSourceOutputDirectory = project.file(generatedJavaDir)
aptOptions {
processorArgs = [ 'querydsl.generatedAnnotationClass' : 'com.querydsl.core.annotations.Generated' ]
}
} }
war { war {
@@ -257,11 +252,11 @@ eclipse {
} }
synchronizationTasks settingEclipseEncoding, initDirs synchronizationTasks settingEclipseEncoding, initDirs
jdt { jdt {
apt { // apt {
// generated 된 패스 경로를 .setting/org.eclipse.jdt.apt.core.prefs 의 org.eclipse.jdt.apt.genSrcDir에 적용한다. // // generated 된 패스 경로를 .setting/org.eclipse.jdt.apt.core.prefs 의 org.eclipse.jdt.apt.genSrcDir에 적용한다.
// project > properties > Java Compiler > Annoation Processing 화면에서 확인 가능하다. // // project > properties > Java Compiler > Annoation Processing 화면에서 확인 가능하다.
genSrcDir = file(generatedJavaDir) // genSrcDir = file(generatedJavaDir)
} // }
} }
project { project {
if (!natures.contains('org.eclipse.buildship.core.gradleprojectnature')) { if (!natures.contains('org.eclipse.buildship.core.gradleprojectnature')) {
@@ -448,14 +448,24 @@ public class ApiSpecManService {
// 헤더/바디 분리 // 헤더/바디 분리
RequestComponents components = separateHeaderAndBody(requestLayout.getLayoutItems()); RequestComponents components = separateHeaderAndBody(requestLayout.getLayoutItems());
// 헤더 파라미터 처리 boolean bodyRequired = isRequestBodyRequired(httpMethod);
if (!components.headerItems.isEmpty()) {
// 헤더 파라미터 + (POST/PUT 이 아니면) 요청 바디 항목을 쿼리스트링 파라미터로 함께 담는다.
boolean hasHeader = !components.headerItems.isEmpty();
boolean queryFromBody = !bodyRequired && !components.bodyItems.isEmpty();
if (hasHeader || queryFromBody) {
ArrayNode parameters = operation.putArray("parameters"); ArrayNode parameters = operation.putArray("parameters");
generateHeaderParameters(parameters, components.headerItems); if (hasHeader) {
generateHeaderParameters(parameters, components.headerItems);
}
// GET/DELETE 등 바디 미사용 메소드: 요청 레이아웃 바디 항목 → 쿼리 파라미터(in=query)
if (queryFromBody) {
generateQueryParameters(parameters, components.bodyItems);
}
} }
// Body 처리 (POST, PUT 메소드인 경우) // Body 처리 (POST, PUT 메소드인 경우)
if (isRequestBodyRequired(httpMethod)) { if (bodyRequired) {
ObjectNode requestBody = operation.putObject("requestBody"); ObjectNode requestBody = operation.putObject("requestBody");
requestBody.put("required", true); requestBody.put("required", true);
ObjectNode content = requestBody.putObject("content"); ObjectNode content = requestBody.putObject("content");
@@ -561,6 +571,35 @@ public class ApiSpecManService {
} }
} }
/**
* GET/DELETE 등 바디 미사용 메소드: 요청 레이아웃 바디 항목을 쿼리스트링 파라미터(in=query)로 매핑.
* 스칼라 FIELD 만 대상(GROUP/GRID 중첩 객체·배열은 쿼리스트링에 부적합하여 제외), root(serno 0)는 스킵.
*/
private void generateQueryParameters(ArrayNode parameters, List<LayoutItemUI> bodyItems) {
for (LayoutItemUI item : bodyItems) {
if (item.getLoutItemSerno() != null && item.getLoutItemSerno() == 0) {
continue;
}
if (!"FIELD".equalsIgnoreCase(item.getLoutItemType())) {
continue;
}
ObjectNode parameter = parameters.addObject();
parameter.put("name", item.getLoutItemName());
parameter.put("in", "query");
if (StringUtils.isNotEmpty(item.getLoutItemDesc())) {
parameter.put("description", item.getLoutItemDesc());
}
parameter.put("required", false); // 기본값 false, 필요시 사용자가 변경
ObjectNode schema = parameter.putObject("schema");
schema.put("type", mapSwaggerType(item.getLoutItemDataType()));
int length = item.getLoutItemLength();
if (length > 0) {
schema.put("maxLength", length);
}
}
}
private void generateResponseHeaders(ObjectNode headers, List<LayoutItemUI> headerItems) { private void generateResponseHeaders(ObjectNode headers, List<LayoutItemUI> headerItems) {
for (LayoutItemUI item : headerItems) { for (LayoutItemUI item : headerItems) {
if ("FIELD".equalsIgnoreCase(item.getLoutItemType())) { if ("FIELD".equalsIgnoreCase(item.getLoutItemType())) {