API 그룹 팝업 JSP 파일 추가 - 예제 코드 및 UI 구조 작성

Summernote 에디터 스타일 CSS 추가 - 텍스트 및 컴포넌트 스타일 정의
OpenAPI POC JS 파일 추가 - API 스펙 빌더 및 스텝별 렌더 구현
This commit is contained in:
Rinjae
2026-07-13 14:25:09 +09:00
parent d7dcf9a81c
commit cbf3066a56
27 changed files with 4405 additions and 91 deletions
@@ -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<ApiGroup, String, ApiGr
.where(qApiGroup.id.in(apiGroupIds))
.fetch();
}
/** API 를 지정한 그룹 목록에만 소속시킨다(기존 소속 제거 후 재설정). */
public void setApiGroupsForApi(String apiId, List<String> 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<ApiGroupApi> 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);
}
}
}
}
@@ -14,6 +14,7 @@ public interface PortalPropertyMapper extends GenericMapper<PropertyUI, PortalPr
@Mapping(source = "prptyGroupName", target = "id.propertyGroupName")
@Mapping(source = "prptyName", target = "id.propertyName")
@Mapping(source = "prpty2Val", target = "propertyValue")
@Mapping(source = "prptyDesc", target = "propertyDesc")
@Override
PortalProperty toEntity(PropertyUI vo);
@@ -21,6 +22,7 @@ public interface PortalPropertyMapper extends GenericMapper<PropertyUI, PortalPr
@Mapping(source ="id.propertyGroupName", target = "prptyGroupName")
@Mapping(source ="id.propertyName", target = "prptyName")
@Mapping(source ="propertyValue", target = "prpty2Val")
@Mapping(source ="propertyDesc", target = "prptyDesc")
@InheritInverseConfiguration
@Override
PropertyUI toVo(PortalProperty entity);
@@ -15,4 +15,7 @@ public class PropertyUI {
@JsonProperty("PRPTY2VAL")
private String prpty2Val;
@JsonProperty("PRPTYDESC")
private String prptyDesc;
}
@@ -50,7 +50,7 @@ public class ApiSpecController extends OnlBaseAnnotationController {
return "/onl/transaction/apim/apiSpecManPopup";
}
@GetMapping(value = "/onl/transaction/apim/apiSpecManPopup.view", params = "cmd=POPUP_LIST")
@GetMapping(value = "/onl/transaction/apim/apiSpecMan.view", params = "cmd=POPUP_LIST")
public String detailOrgPopupView() {
return "/onl/transaction/apim/apiSpecManDisplayOrgPopup";
}
@@ -0,0 +1,541 @@
package com.eactive.eai.rms.onl.transaction.apim;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import org.apache.commons.lang.StringEscapeUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
import com.eactive.eai.rms.common.base.OnlBaseAnnotationController;
import com.eactive.eai.rms.data.entity.onl.eaimsg.EAIMessageService;
import com.eactive.eai.rms.onl.manage.rule.layout.ui.LayoutUI;
import com.eactive.eai.rms.onl.transaction.apim.ui.ApiInterfaceUI;
import com.eactive.eai.rms.onl.transaction.apim.ui.ApiSpecInfoUI;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import com.eactive.eai.rms.data.entity.onl.apim.apigroup.ApiGroupService;
import com.eactive.eai.data.entity.onl.apim.apigroup.ApiGroup;
import org.springframework.ui.Model;
/**
* 신규 OpenAPI Spec 6단계 편집 마법사 컨트롤러.
*
* apiInterfaceMan 상세의 "OpenAPI Spec" 버튼이 새 탭(_blank)으로 여는 페이지 및 마법사가 호출하는
* cmd API 를 제공한다. 저장은 기존 컬럼 + testbedSpec(OpenAPI JSON)에 매핑(스키마 무변경, 경량).
*
* (MVC 컨트롤러는 springapp-servlet.xml 의 com.eactive.eai.rms 스캔 범위에서만 등록되므로 본 컨트롤러는
* 해당 패키지에 둔다. 기존 apiSpecMan.view(v1)와 URL 을 분리해 공존한다.)
*/
@Controller
public class DjbApiSpecController extends OnlBaseAnnotationController {
@Autowired
private ApiSpecManService apiSpecManService;
@Autowired
private ApiInterfaceService apiInterfaceService;
@Autowired
private EAIMessageService eaiMessageService;
@Autowired
private DjbApiSpecSwaggerEnricher swaggerEnricher;
@Autowired
private DjbApiSpecLayoutMergeService layoutMergeService;
@Autowired
private PortalPropertyService portalPropertyService;
@Autowired
private ApiGroupService apiGroupService;
private final ObjectMapper objectMapper = new ObjectMapper();
// GW base URL 단일 기준 = 포탈 DjbTestbedGatewayProperty 의 djb.gateway.base-url (PTL_PROPERTY 그룹 'Portal').
// 별도 swagger.gw.address 프로퍼티는 두지 않는다.
private static final String GW_BASE_URL_KEY = "djb.gateway.base-url";
private static final String GW_BASE_URL_DEFAULT = "PortalMock";
// "PortalMock" 은 포탈이 요청 origin 으로 치환하는 sentinel 이라 admin 미리보기용 서버 주소로는 부적합 → 대체값 사용.
private static final String GW_PREVIEW_FALLBACK = "http://127.0.0.1:39310";
// ── 진입 ────────────────────────────────────────────────
@GetMapping(value = "/onl/transaction/apim/djbApiSpecMan.view", params = "cmd=DETAIL")
public String viewDetail(Model model) {
// GW base URL (djb.gateway.base-url, 그룹 'Portal') — 없으면 기본값으로 생성. DjbTestbedGatewayProperty 와 동일 키.
String gw = portalPropertyService.getOrCreateProperty(
"Portal", GW_BASE_URL_KEY, GW_BASE_URL_DEFAULT, "GW base URL (테스트베드 공통)");
if (StringUtils.isBlank(gw) || GW_BASE_URL_DEFAULT.equalsIgnoreCase(gw.trim())) {
gw = GW_PREVIEW_FALLBACK; // PortalMock → admin 미리보기용 구체 주소로 대체
}
model.addAttribute("gwAddress", gw.trim());
return "/onl/transaction/apim/djbApiSpecManPopup";
}
// API 그룹 선택 팝업(iframe 모달 대상). 그룹 목록 그리드는 apiGroupMan.json?cmd=LIST 재사용.
@GetMapping(value = "/onl/transaction/apim/djbApiSpecMan.view", params = "cmd=GROUP_POPUP")
public String viewGroupPopup() {
return "/onl/transaction/apim/djbApiGroupPopup";
}
// ── 조회: 저장 spec 우선 + authtype securityScheme 주입 ──
@PostMapping(value = "/onl/transaction/apim/djbApiSpecMan.json", params = "cmd=DETAIL_SPEC")
@ResponseBody
public ResponseEntity<Map<String, String>> detailSpec(String eaiSvcName, String regen) throws Exception {
Map<String, String> result = new HashMap<>();
if (StringUtils.isBlank(eaiSvcName)) {
result.put("status", "fail");
result.put("errorMsg", "eaiSvcName 이 없습니다.");
return ResponseEntity.badRequest().body(result);
}
// regen=true 이면 저장본 무시하고 레이아웃 기반으로 강제 재생성 (자동 생성/초기화)
boolean force = "true".equalsIgnoreCase(regen);
String spec;
String source;
ApiSpecInfoUI saved = force ? null : apiSpecManService.selectDetail(eaiSvcName);
if (!force && saved != null && StringUtils.isNotBlank(saved.getTestbedSpec())) {
spec = saved.getTestbedSpec();
source = "saved";
} else {
spec = generateSpec(eaiSvcName);
source = force ? "regenerated" : "generated";
}
String authtype = eaiMessageService.findById(eaiSvcName)
.map(EAIMessageEntity::getAuthtype)
.orElse(null);
String enriched = swaggerEnricher.enrich(spec, authtype);
result.put("testbedSpec", enriched);
result.put("source", source);
result.put("authType", authtype == null ? "" : authtype);
// API 그룹 소속(조인테이블 ApiGroupApi) — 저장 여부와 무관하게 현재 소속 반환
try {
ArrayNode groups = objectMapper.createArrayNode();
for (ApiGroup g : apiGroupService.findByApiId(eaiSvcName)) {
ObjectNode gn = objectMapper.createObjectNode();
gn.put("id", g.getId());
gn.put("groupName", g.getGroupName());
groups.add(gn);
}
result.put("apiGroups", objectMapper.writeValueAsString(groups));
} catch (Exception e) {
logger.warn("API 그룹 조회 실패: " + eaiSvcName, e);
}
// 저장본이면 폼 복원용 기본정보/공개설정도 함께 반환
if (saved != null) {
putIfNotNull(result, "apiName", saved.getApiName());
putIfNotNull(result, "apiSimpleDescription", saved.getApiSimpleDescription());
putIfNotNull(result, "apiUrl", saved.getApiUrl());
putIfNotNull(result, "apiMethod", saved.getApiMethod());
putIfNotNull(result, "apiContentType", saved.getApiContentType());
putIfNotNull(result, "description", saved.getDescription());
putIfNotNull(result, "apiRequestSpec", saved.getApiRequestSpec());
putIfNotNull(result, "apiResponseSpec", saved.getApiResponseSpec());
putIfNotNull(result, "sampleRequest", saved.getSampleRequest());
putIfNotNull(result, "sampleResponse", saved.getSampleResponse());
putIfNotNull(result, "responseType", saved.getResponseType());
putIfNotNull(result, "mockUrl", saved.getMockUrl());
putIfNotNull(result, "displayYn", saved.getDisplayYn());
putIfNotNull(result, "displayRoleCode", saved.getDisplayRoleCode());
putIfNotNull(result, "displayOrg", saved.getDisplayOrg());
}
// 예제/설명자료(요청·응답 메시지 스펙 HTML)는 프론트에서 스키마 기준으로 생성 → 자동생성/재생성 결과 일치.
// 저장본이면 위 saved 블록에서 저장값을 이미 반환.
return ResponseEntity.ok(result);
}
// ── 저장: 마법사 폼 → ApiSpecInfoUI → apiSpecManService.save (기존 재사용) ──
@PostMapping(value = "/onl/transaction/apim/djbApiSpecMan.json", params = "cmd=SAVE")
@ResponseBody
public ResponseEntity<Map<String, String>> save(ApiSpecInfoUI apiSpecInfoUI, String apiGroupIds) {
Map<String, String> result = new HashMap<>();
if (apiSpecInfoUI == null || StringUtils.isBlank(apiSpecInfoUI.getApiId())) {
result.put("status", "fail");
result.put("errorMsg", "API ID 가 없습니다.");
return ResponseEntity.badRequest().body(result);
}
// CrossScriptingFilter(RequestWrapper.cleanXSS)가 요청 파라미터의 특수문자를 엔티티로 치환
// ('('→&#40; ')'→&#41; '"'→&quot; 등)하므로, 저장 전 모든 자유텍스트/JSON 필드를 언이스케이프해 원복한다.
// (JSON 필드는 특히 '"'→&quot; 로 깨지므로 필수)
apiSpecInfoUI.setApiName(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getApiName()));
apiSpecInfoUI.setApiSimpleDescription(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getApiSimpleDescription()));
apiSpecInfoUI.setApiUrl(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getApiUrl()));
apiSpecInfoUI.setMockUrl(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getMockUrl()));
apiSpecInfoUI.setApiRequestSpec(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getApiRequestSpec()));
apiSpecInfoUI.setApiResponseSpec(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getApiResponseSpec()));
apiSpecInfoUI.setSampleRequest(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getSampleRequest()));
apiSpecInfoUI.setSampleResponse(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getSampleResponse()));
apiSpecInfoUI.setDescription(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getDescription()));
apiSpecInfoUI.setTestbedSpec(StringEscapeUtils.unescapeHtml(apiSpecInfoUI.getTestbedSpec()));
apiSpecManService.save(apiSpecInfoUI);
// API 그룹 소속(조인테이블) 동기화 — apiGroupIds(CSV) 로 재설정
try {
java.util.List<String> 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);
}
// ── 레이아웃 불러오기 (GW) ──
@PostMapping(value = "/onl/transaction/apim/djbApiSpecMan.json", params = "cmd=LOAD_REQUEST_LAYOUT")
@ResponseBody
public ResponseEntity<Map<String, Object>> loadRequestLayout(String eaiSvcName) throws Exception {
return loadLayout(eaiSvcName, true);
}
@PostMapping(value = "/onl/transaction/apim/djbApiSpecMan.json", params = "cmd=LOAD_RESPONSE_LAYOUT")
@ResponseBody
public ResponseEntity<Map<String, Object>> loadResponseLayout(String eaiSvcName) throws Exception {
return loadLayout(eaiSvcName, false);
}
// ── HTML 테이블 생성 (기존 generateSpecTableFromLayout 재사용) ──
@PostMapping(value = "/onl/transaction/apim/djbApiSpecMan.json", params = "cmd=GENERATE_HTML")
@ResponseBody
public ResponseEntity<Map<String, String>> generateHtml(String eaiSvcName, String layoutType) throws Exception {
LayoutUI layout = resolveLayout(eaiSvcName, !"RESPONSE".equalsIgnoreCase(layoutType));
Map<String, String> result = new HashMap<>();
result.put("html", apiSpecManService.generateSpecTableFromLayout(layout));
return ResponseEntity.ok(result);
}
// ── 메시지 예제 생성 (기존 generateSampleDataFromLayout 재사용) ──
@PostMapping(value = "/onl/transaction/apim/djbApiSpecMan.json", params = "cmd=GENERATE_SAMPLE")
@ResponseBody
public ResponseEntity<Map<String, String>> generateSample(String eaiSvcName, String layoutType) throws Exception {
LayoutUI layout = resolveLayout(eaiSvcName, !"RESPONSE".equalsIgnoreCase(layoutType));
Map<String, String> result = new HashMap<>();
result.put("json", apiSpecManService.generateSampleDataFromLayout(layout));
return ResponseEntity.ok(result);
}
// ── 내부 헬퍼 ──────────────────────────────────────────
private ResponseEntity<Map<String, Object>> loadLayout(String eaiSvcName, boolean request) throws Exception {
Map<String, Object> res = new LinkedHashMap<>();
List<String> warnings = new ArrayList<>();
if (StringUtils.isBlank(eaiSvcName)) {
res.put("layoutType", request ? "REQUEST" : "RESPONSE");
res.put("fields", new ArrayList<>());
warnings.add("eaiSvcName 이 없습니다.");
res.put("warnings", warnings);
return ResponseEntity.ok(res);
}
LayoutUI layout = resolveLayout(eaiSvcName, request);
if (layout == null || layout.getLayoutItems() == null || layout.getLayoutItems().isEmpty()) {
warnings.add((request ? "요청" : "응답") + " 레이아웃이 없습니다.");
}
res.put("layoutType", request ? "REQUEST" : "RESPONSE");
res.put("fields", layoutMergeService.toFields(layout, request ? "REQUEST" : "RESPONSE"));
res.put("warnings", warnings);
return ResponseEntity.ok(res);
}
private LayoutUI resolveLayout(String eaiSvcName, boolean request) throws Exception {
ApiInterfaceUI apiInterfaceUI = apiInterfaceService.selectDetail(eaiSvcName);
if (apiInterfaceUI == null) {
return null;
}
String layoutName = request ? apiInterfaceUI.getInboundRequestLayout() : apiInterfaceUI.getInboundResponseLayout();
return StringUtils.isNotEmpty(layoutName) ? apiSpecManService.selectLayoutUI(layoutName) : null;
}
/**
* 레이아웃 기반 자동 생성 + 자동생성 규칙(백엔드 단일 소스).
* 규칙: title/tag/summary = API명(eaiSvcDesc), operationId = 인터페이스ID,
* servers[0].url 을 path 앞에 붙이고 servers 제거(서버 선택 제거),
* 요청/200 응답 예제를 레이아웃 샘플로 임베드.
*/
private String generateSpec(String eaiSvcName) throws Exception {
ApiInterfaceUI apiInterfaceUI = apiInterfaceService.selectDetail(eaiSvcName);
if (apiInterfaceUI == null) {
throw new IllegalStateException("API 인터페이스 정보를 찾을 수 없습니다: " + eaiSvcName);
}
Map<String, String> inboundAdapterSpec = apiInterfaceService.getHttpAdapterInfo(apiInterfaceUI.getFromAdapter());
if (inboundAdapterSpec == null) {
inboundAdapterSpec = new HashMap<>();
}
String requestLayoutName = apiInterfaceUI.getInboundRequestLayout();
String responseLayoutName = apiInterfaceUI.getInboundResponseLayout();
LayoutUI requestLayoutUI = StringUtils.isNotEmpty(requestLayoutName)
? apiSpecManService.selectLayoutUI(requestLayoutName) : null;
LayoutUI responseLayoutUI = StringUtils.isNotEmpty(responseLayoutName)
? apiSpecManService.selectLayoutUI(responseLayoutName) : null;
String baseSpec = apiSpecManService.generateSwaggerSpec(requestLayoutUI, responseLayoutUI, inboundAdapterSpec, apiInterfaceUI);
String apiName = StringUtils.isNotBlank(apiInterfaceUI.getEaiSvcDesc()) ? apiInterfaceUI.getEaiSvcDesc() : eaiSvcName;
String reqSample = apiSpecManService.generateSampleDataFromLayout(requestLayoutUI);
String resSample = apiSpecManService.generateSampleDataFromLayout(responseLayoutUI);
String contentType = StringUtils.isNotBlank(apiInterfaceUI.getRestContentType()) ? apiInterfaceUI.getRestContentType() : "application/json";
return applyAutoRules(baseSpec, apiName, eaiSvcName, reqSample, resSample, contentType);
}
/** 자동생성 규칙 후처리(Jackson 트리 조작). 실패 시 원본 유지. */
private String applyAutoRules(String specJson, String apiName, String eaiSvcName, String reqSample, String resSample, String contentType) {
try {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
root.put("openapi", "3.1.0"); // OpenAPI 3.1 방출(Swagger UI 5.x oas31 렌더 지원)
getOrCreateObject(root, "info").put("title", apiName);
String tagName = "DJBank"; // 자동생성 태그명 고정
ArrayNode tags = objectMapper.createArrayNode();
ObjectNode tag = objectMapper.createObjectNode();
tag.put("name", tagName);
tag.put("description", apiName); // 태그 설명 = 간단설명(API명)
tags.add(tag);
root.set("tags", tags);
// 어댑터경로(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;
List<String> pathKeys = new ArrayList<>();
Iterator<String> pit = paths.fieldNames();
while (pit.hasNext()) {
pathKeys.add(pit.next());
}
for (String pk : pathKeys) {
JsonNode pathItem = paths.get(pk);
if (pathItem != null && pathItem.isObject()) {
List<String> methods = new ArrayList<>();
Iterator<String> mit = pathItem.fieldNames();
while (mit.hasNext()) {
methods.add(mit.next());
}
for (String mk : methods) {
JsonNode opNode = pathItem.get(mk);
if (opNode != null && opNode.isObject()) {
ObjectNode op = (ObjectNode) opNode;
op.put("operationId", eaiSvcName);
op.put("summary", apiName);
ArrayNode opTags = objectMapper.createArrayNode();
opTags.add(tagName);
op.set("tags", opTags);
// 예제(요청/응답 본문)는 프론트에서 스키마 기준으로 생성 → 자동생성/재생성 결과 일치. 여기선 임베드하지 않음.
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);
} catch (Exception e) {
logger.error("applyAutoRules 실패, 원본 spec 유지", e);
return specJson;
}
}
private void setContentExample(ObjectNode op, String sampleJson) {
if (StringUtils.isBlank(sampleJson)) {
return;
}
JsonNode body = op.get("requestBody");
if (body == null || !body.isObject()) {
return;
}
JsonNode content = body.get("content");
if (content == null || !content.isObject()) {
return;
}
Iterator<String> mts = content.fieldNames();
if (!mts.hasNext()) {
return;
}
JsonNode mtNode = content.get(mts.next());
if (mtNode != null && mtNode.isObject()) {
((ObjectNode) mtNode).set("example", parseJsonOrText(sampleJson));
}
}
private void setResponseExample(ObjectNode op, String code, String sampleJson) {
if (StringUtils.isBlank(sampleJson)) {
return;
}
JsonNode resps = op.get("responses");
if (resps == null || !resps.isObject()) {
return;
}
JsonNode r = resps.get(code);
if (r == null || !r.isObject()) {
return;
}
ObjectNode content = getOrCreateObject((ObjectNode) r, "content");
ObjectNode appjson = getOrCreateObject(content, "application/json");
appjson.set("example", parseJsonOrText(sampleJson));
}
/** GW 유래 스키마 필드에 x-djb-gw 마커 부여(프론트에서 잠금 표시). */
private void markGwOnOperation(ObjectNode op) {
JsonNode body = op.get("requestBody");
if (body != null && body.isObject()) {
markGwInContent(body.get("content"));
}
JsonNode resps = op.get("responses");
if (resps != null && resps.isObject()) {
List<String> codes = new ArrayList<>();
Iterator<String> it = resps.fieldNames();
while (it.hasNext()) {
codes.add(it.next());
}
for (String c : codes) {
JsonNode r = resps.get(c);
if (r != null && r.isObject()) {
markGwInContent(r.get("content"));
}
}
}
}
private void markGwInContent(JsonNode content) {
if (content == null || !content.isObject()) {
return;
}
List<String> mts = new ArrayList<>();
Iterator<String> it = content.fieldNames();
while (it.hasNext()) {
mts.add(it.next());
}
for (String mt : mts) {
JsonNode m = content.get(mt);
if (m != null && m.isObject()) {
markGwSchema(m.get("schema"));
}
}
}
private void markGwSchema(JsonNode schema) {
if (schema == null || !schema.isObject()) {
return;
}
ObjectNode s = (ObjectNode) schema;
JsonNode props = s.get("properties");
if (props != null && props.isObject()) {
ArrayNode required = (s.get("required") != null && s.get("required").isArray())
? (ArrayNode) s.get("required") : objectMapper.createArrayNode();
java.util.Set<String> existing = new java.util.HashSet<>();
required.forEach(n -> existing.add(n.asText()));
List<String> keys = new ArrayList<>();
Iterator<String> it = props.fieldNames();
while (it.hasNext()) {
keys.add(it.next());
}
for (String k : keys) {
JsonNode p = props.get(k);
if (p != null && p.isObject()) {
((ObjectNode) p).put("x-djb-gw", true);
if (!existing.contains(k)) { // GW 필드는 기본 '필수'
required.add(k);
existing.add(k);
}
markGwSchema(p);
}
}
if (required.size() > 0) {
s.set("required", required);
}
}
JsonNode items = s.get("items");
if (items != null && items.isObject()) {
markGwSchema(items);
}
}
/** 응답 헤더 미리 등록(중복 시 스킵). */
private void addResponseHeader(ObjectNode op, String code, String headerName, String example) {
JsonNode resps = op.get("responses");
if (resps == null || !resps.isObject()) {
return;
}
JsonNode r = resps.get(code);
if (r == null || !r.isObject()) {
return;
}
ObjectNode headers = getOrCreateObject((ObjectNode) r, "headers");
if (headers.has(headerName)) {
return;
}
ObjectNode h = objectMapper.createObjectNode();
h.put("description", headerName);
ObjectNode sc = objectMapper.createObjectNode();
sc.put("type", "string");
h.set("schema", sc);
h.put("example", example);
headers.set(headerName, h);
}
private JsonNode parseJsonOrText(String s) {
try {
return objectMapper.readTree(s);
} catch (Exception e) {
return TextNode.valueOf(s);
}
}
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
JsonNode node = parent.get(field);
if (node != null && node.isObject()) {
return (ObjectNode) node;
}
ObjectNode created = objectMapper.createObjectNode();
parent.set(field, created);
return created;
}
private void putIfNotNull(Map<String, String> map, String key, String value) {
if (value != null) {
map.put(key, value);
}
}
}
@@ -0,0 +1,60 @@
package com.eactive.eai.rms.onl.transaction.apim;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.eactive.eai.rms.onl.manage.rule.layout.ui.LayoutItemUI;
import com.eactive.eai.rms.onl.manage.rule.layout.ui.LayoutUI;
/**
* D9 레이아웃 머지: GW LayoutUI 를 마법사 그리드용 필드 목록으로 변환한다.
*
* <p>GW 에서 온 필드는 {@code sourceType=GW}(항목명/데이터타입 잠금)로 표기하고, 예제값은
* {@link DjbApiSpecSampleGenerator} 규칙으로 자동 채운다. 저장된 spec 의 부가정보(설명/예제/제약)
* 병합은 클라이언트(그리드)에서 사용자 편집분과 합치므로, 서버는 GW 원본 필드 + 자동 예제까지 제공한다.
*/
@Service
public class DjbApiSpecLayoutMergeService {
@Autowired
private DjbApiSpecSampleGenerator sampleGenerator;
/** LayoutUI → 그리드 필드 행 목록(flat, depth/parentIndex 로 트리 복원 가능). */
public List<Map<String, Object>> toFields(LayoutUI layout, String layoutType) {
List<Map<String, Object>> rows = new ArrayList<>();
if (layout == null || layout.getLayoutItems() == null) {
return rows;
}
for (LayoutItemUI it : layout.getLayoutItems()) {
// serno 0 은 root 컨테이너 → 스킵 (ApiSpecManService.generateSpecTableRows 규칙과 동일)
if (it.getLoutItemSerno() != null && it.getLoutItemSerno() == 0) {
continue;
}
String dataType = it.getLoutItemDataType();
int len = it.getLoutItemLength();
int dec = it.getLoutItemDecimal();
Map<String, Object> r = new LinkedHashMap<>();
r.put("serno", it.getLoutItemSerno());
r.put("name", it.getLoutItemName());
r.put("desc", it.getLoutItemDesc());
r.put("itemType", it.getLoutItemType()); // FIELD / GROUP / GRID
r.put("dataType", dataType);
r.put("category", sampleGenerator.categoryOf(dataType));
r.put("length", len);
r.put("decimal", dec);
r.put("depth", it.getLoutItemDepth());
r.put("parentIndex", it.getParentLoutItemIndex());
r.put("required", false);
r.put("sourceType", "GW");
r.put("example", sampleGenerator.exampleFor(dataType, it.getLoutItemName(), len, dec));
rows.add(r);
}
return rows;
}
}
@@ -0,0 +1,75 @@
package com.eactive.eai.rms.onl.transaction.apim;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
/**
* D10 예제값 자동 생성 규칙.
* <ul>
* <li>string → {@code sample_} + 항목명. 최대길이 초과 시 left-cut(앞에서 자름).</li>
* <li>integer → {@code 1}</li>
* <li>number → {@code 1.1} (소수 자릿수 지정 시 {@code 1.1000} 형태)</li>
* <li>boolean → {@code false}</li>
* </ul>
*/
@Service
public class DjbApiSpecSampleGenerator {
/** 원시 데이터타입(레이아웃 값)을 OpenAPI 계열 카테고리로 분류. */
public String categoryOf(String rawDataType) {
if (rawDataType == null) {
return "string";
}
String t = rawDataType.trim().toLowerCase();
if (t.contains("bool")) {
return "boolean";
}
if (t.contains("decimal") || t.contains("double") || t.contains("float") || t.contains("number")) {
return "number";
}
if (t.contains("int") || t.contains("long")) {
return "integer";
}
if (t.contains("object") || t.contains("group")) {
return "object";
}
if (t.contains("array") || t.contains("grid") || t.contains("list")) {
return "array";
}
return "string";
}
/**
* 예제값 생성.
* @param rawDataType 레이아웃 데이터타입(원시)
* @param fieldName 항목명
* @param maxLen 최대 길이(0/음수면 무제한)
* @param decimalLen 소수 자릿수(0이면 미적용)
*/
public String exampleFor(String rawDataType, String fieldName, int maxLen, int decimalLen) {
switch (categoryOf(rawDataType)) {
case "integer":
return "1";
case "number":
if (decimalLen > 0) {
StringBuilder sb = new StringBuilder("1.");
for (int i = 0; i < decimalLen; i++) {
sb.append(i == 0 ? '1' : '0');
}
return sb.toString();
}
return "1.1";
case "boolean":
return "false";
case "object":
case "array":
return ""; // 컨테이너는 예제값 없음(자식으로 표현)
default:
String v = "sample_" + StringUtils.defaultString(fieldName);
if (maxLen > 0 && v.length() > maxLen) {
v = v.substring(0, maxLen); // left-cut
}
return v;
}
}
}
@@ -0,0 +1,101 @@
package com.eactive.eai.rms.onl.transaction.apim;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
/**
* 생성/저장된 OpenAPI(3.0) spec JSON 에 인증 체계(components.securitySchemes + 글로벌 security)를 주입한다.
*
* <p>포털 {@code DjbSwaggerSpecEnricher} 와 동일 모델링:
* 게이트웨이가 토큰/키를 표준 Authorization 이 아니라 커스텀 헤더로 받으므로 OAuth·API_KEY 모두
* <b>apiKey-in-header</b> 스킴으로 표현(헤더명만 상이).
*
* <p>authtype(TSEAIHE01.AUTHTYPE, 소문자 저장) 매핑:
* {@code oauth/oauth2/ca → OAUTH}, {@code api_key/apikey → API_KEY}, 그 외 → 주입 없음.
* 원본 트리를 보존하며 조작한다(문자열 치환 금지). 매핑 불가/빈 스펙/파싱 실패 시 원본 유지(멱등).
*/
@Service
public class DjbApiSpecSwaggerEnricher {
private final ObjectMapper objectMapper = new ObjectMapper();
public static final String OAUTH_SCHEME = "djbOAuth";
public static final String API_KEY_SCHEME = "djbApiKey";
// 게이트웨이 헤더명 (포털 DjbTestbedGatewayProperty 기본값과 동일)
public static final String OAUTH_HEADER = "X-AUTH-TOKEN";
public static final String API_KEY_HEADER = "X-DJB-API-Key";
/** authtype → 스킴명. 매핑 불가면 null. */
public String schemeNameOf(String authtype) {
if (authtype == null) {
return null;
}
switch (authtype.trim().toLowerCase()) {
case "oauth":
case "oauth2":
case "ca":
return OAUTH_SCHEME;
case "api_key":
case "apikey":
return API_KEY_SCHEME;
default:
return null;
}
}
/**
* spec JSON 에 authtype 기반 securityScheme 주입. 매핑 불가/빈 스펙/파싱 실패 시 원본 그대로 반환.
*/
public String enrich(String specJson, String authtype) {
String schemeName = schemeNameOf(authtype);
if (StringUtils.isBlank(specJson) || schemeName == null) {
return specJson;
}
try {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
ObjectNode scheme = objectMapper.createObjectNode();
scheme.put("type", "apiKey");
scheme.put("in", "header");
scheme.put("name", OAUTH_SCHEME.equals(schemeName) ? OAUTH_HEADER : API_KEY_HEADER);
boolean swagger2 = root.has("swagger") && !root.has("openapi");
if (swagger2) {
getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme);
} else {
ObjectNode components = getOrCreateObject(root, "components");
getOrCreateObject(components, "securitySchemes").set(schemeName, scheme);
}
// 글로벌 security 요구사항 (apiKey 스킴은 빈 스코프 배열)
ArrayNode security = objectMapper.createArrayNode();
ObjectNode requirement = objectMapper.createObjectNode();
requirement.set(schemeName, objectMapper.createArrayNode());
security.add(requirement);
root.set("security", security);
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
return specJson; // 파싱/직렬화 실패 → 원본 유지(멱등)
}
}
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
JsonNode node = parent.get(field);
if (node != null && node.isObject()) {
return (ObjectNode) node;
}
ObjectNode created = objectMapper.createObjectNode();
parent.set(field, created);
return created;
}
}
+183
View File
@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="UTF-8" ?>
<configuration scan="true" scanPeriod="10 seconds">
<statusListener class="ch.qos.logback.core.status.OnConsoleStatusListener" />
<property name="LOG_PATTERN" value="[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] \\(%F:%L\\) %-20M -%msg%n" />
<property name="PREFIX" value="${inst.Type:-ems}" />
<property name="LOG_HOME" value="/Users/rinjae/eactive/djb-eapim/djb-eapim/eapim-admin/logs" />
<property name="BACKUP_HOME" value="${LOG_HOME}/backup/" />
<property name="LOG_LEVEL" value="${LOGBACK_LOG_LEVEL:-DEBUG}" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<charset>utf-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="STDOUT"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${PREFIX}_stdout.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>
${LOG_HOME}/${PREFIX}_stdout.log-%d{yyyy-MM-dd}.%i
</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<charset>utf-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="ACCESS_APPENDER"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${PREFIX}_access.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>
${LOG_HOME}/${PREFIX}_access.log-%d{yyyy-MM-dd}.%i
</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
</rollingPolicy>
<encoder>
<charset>utf-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="SMS_APPENDER"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/sms.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>
${LOG_HOME}/sms.log-%d{yyyy-MM-dd}.%i
</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<charset>utf-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="QUERY_APPENDER"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${PREFIX}_query.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>
${LOG_HOME}/${PREFIX}_query.log-%d{yyyy-MM-dd}.%i
</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<charset>utf-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="DYNAMIC_APPENDER"
class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${PREFIX}_dynamic.log</file>
<rollingPolicy
class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>
${LOG_HOME}/${PREFIX}_dynamic.log-%d{yyyy-MM-dd}.%i
</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<charset>utf-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${PREFIX}_hibernate.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${PREFIX}_hibernate.log-%d{yyyy-MM-dd}.%i</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<charset>utf-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<appender name="HOTSWAP_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${PREFIX}_hotswap.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${PREFIX}_hotswap.log-%d{yyyy-MM-dd}.%i</fileNamePattern>
<maxFileSize>500MB</maxFileSize>
<maxHistory>10</maxHistory>
</rollingPolicy>
<encoder>
<charset>utf-8</charset>
<pattern>${LOG_PATTERN}</pattern>
</encoder>
</appender>
<logger name="com.eactive.eai.rms.onl.server" level="ERROR" />
<logger name="com.eactive.eai.rms.onl.dashboard" level="ERROR" />
<logger name="com.eactive.eai.rms.common.acl.user.BizDao" level="ERROR" />
<logger name="com.eactive.eai.rms.common.acl.user.BizDao" level="ERROR" />
<logger name="com.eactive.eai.rms.ext.djb.job.ApiStatusMonitorJob" level="ERROR" />
<logger name="org.springframework.orm.jpa.JpaTransactionManager" level="ERROR" additivity="false" />
<logger name="org.springframework" level="INFO" />
<logger name="net.sf.ehcache" level="INFO" />
<logger name="ACCESS_LOGGER" level="DEBUG" >
<appender-ref ref="ACCESS_APPENDER" />
</logger>
<logger name="SMS_LOGGER" level="DEBUG" >
<appender-ref ref="SMS_APPENDER" />
</logger>
<logger name="DYNAMIC_LOGGER" level="DEBUG" >
<appender-ref ref="DYNAMIC_APPENDER" />
</logger>
<!-- QUERY_APPENDER start -->
<!-- Hibernate SQL & 파라미터 바인딩 -->
<logger name="org.hibernate.SQL" level="DEBUG" additivity="false">
<appender-ref ref="QUERY_APPENDER" />
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="TRACE" additivity="false">
<appender-ref ref="QUERY_APPENDER" />
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<logger name="org.hibernate.type.descriptor.sql.BasicExtractor" level="TRACE" additivity="false">
<appender-ref ref="QUERY_APPENDER" />
<appender-ref ref="HIBERNATE_APPENDER" />
</logger>
<!-- Hibernate 일반 로그 (DDL/init/connection 등) -->
<logger name="org.hibernate" level="INFO" additivity="false">
<appender-ref ref="HIBERNATE_APPENDER" />
<appender-ref ref="STDOUT" />
</logger>
<!-- iBATIS 2.3.4 (commons-logging 또는 log4j-over-slf4j 통해 SLF4J 도달) -->
<logger name="com.ibatis" level="DEBUG" additivity="false">
<appender-ref ref="QUERY_APPENDER" />
</logger>
<logger name="com.eactive.eai.rms.common.base.SchemaIdConfiguredSqlMapTemplate" level="DEBUG" additivity="false">
<appender-ref ref="QUERY_APPENDER" />
</logger>
<!-- QUERY_APPENDER end -->
<!-- HotswapAgent는 자체 logger를 사용하므로 logback에 안 잡힘. hotswap-agent.properties의 LOGFILE로 직접 파일 출력 -->
<root>
<level value="INFO" />
<appender-ref ref="STDOUT" />
<appender-ref ref="CONSOLE" />
</root>
</configuration>