API 그룹 팝업 JSP 추가 - Swagger Validator JS 등록 - Summernote CSS 추가

- API 그룹 팝업(djbApiGroupPopup.jsp) 화면 파일 추가
- Swagger Validator (djb-swagger-validator.js) 및 관련 리팩토링
- Summernote 콘텐츠 스타일 정의 (editor-content.css) 추가
This commit is contained in:
Rinjae
2026-07-03 16:54:47 +09:00
parent 5d70962521
commit 3c103ad3a4
16 changed files with 867 additions and 90 deletions
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.FileCopyUtils;
@@ -22,35 +22,45 @@ import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/apis")
@RequiredArgsConstructor
@Slf4j
public class TestbedSpecController {
@Autowired
private ApiSpecInfoService apiSpecInfoService;
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedSpecServerRewriter serverRewriter;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> getSwagger(@PathVariable String id) {
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
try {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
return ResponseEntity.ok().body(content);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
} catch (IOException e) {
log.error("Failed to read default token api spec file", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
return null;
}
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent()) {
StringUtils.hasText(spec.get().getTestbedSpec());
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
}
}
@@ -1,5 +1,6 @@
package com.eactive.apim.portal.apps.apis.filter;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
@@ -20,6 +21,20 @@ public class APISender {
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
private final DjbTestbedGatewayProperty gatewayProperty;
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
this.gatewayProperty = gatewayProperty;
}
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
private void applyTimeout(HttpURLConnection connection) {
int t = gatewayProperty.timeoutMillis();
connection.setConnectTimeout(t);
connection.setReadTimeout(t);
}
public String requestPost(String uri, String requestBody) throws IOException {
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
@@ -58,6 +73,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "application/json");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -82,6 +98,7 @@ public class APISender {
URL endpoint = new URL(appendUriAndParams(uri, params));
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
for (Map.Entry<String, String> entry : headers.entrySet()) {
@@ -125,10 +142,11 @@ public class APISender {
return response.toString();
}
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
URL endpoint = new URL(uri);
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
applyTimeout(connection);
connection.setRequestMethod("POST");
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
@@ -22,6 +22,7 @@ import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -67,6 +68,16 @@ public class ApiTesterFilter implements Filter {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url");
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) {
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return;
}
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
// 반환하기 위해 try 로 감싼다.
try {
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
@@ -120,57 +131,136 @@ public class ApiTesterFilter implements Filter {
} else {
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) {
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return;
}
String responseType = apiSpecInfoDto.getResponseType();
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return;
}
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType);
Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
}
headers.remove("original-url");
headers.remove("original-api-id");
String targetUri;
Map<String, String[]> paramMap;
if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host");
headers.remove("Host");
targetUri = url;
paramMap = new HashMap<>();
} else {
String mockUrl = apiSpecInfoDto.getMockUrl();
String method = apiSpecInfoDto.getApiMethod();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
targetUri = apiSpecInfoDto.getMockUrl();
paramMap = extractQueryParams(url);
}
Map<String, String> headers = new HashMap<>();
while (headerNames.hasMoreElements()) {
String header = headerNames.nextElement();
headers.put(header, httpServletRequest.getHeader(header));
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
} else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
} catch (java.net.SocketTimeoutException e) {
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (IOException e) {
// 연결 실패 등 네트워크 오류
logger.error("테스트베드 프록시 호출 실패", e);
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} catch (Exception e) {
// 그 외 예기치 못한 오류도 JSON 으로 반환
logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
}
}
/** 요청 본문 전체를 문자열로 읽는다. */
private String readBody(HttpServletRequest request) throws IOException {
StringBuilder sb = new StringBuilder();
BufferedReader reader = request.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
private Map<String, String[]> extractQueryParams(String originalUrl) {
Map<String, String[]> paramMap = new HashMap<>();
String[] parts = originalUrl.split("\\?");
if (parts.length > 1) {
for (String param : parts[1].split("&")) {
String[] kv = param.split("=");
if (kv.length > 1) {
paramMap.put(kv[0], new String[]{kv[1]});
}
Map<String, String[]> paramMap = new HashMap<>();
String originalUrl = headers.get("original-url");
//sprlit original url with ? get the second part then split with & put into paramMap
String[] originalUrlArr = originalUrl.split("\\?");
if (originalUrlArr.length > 1) {
String[] paramArr = originalUrlArr[1].split("&");
for (String param : paramArr) {
String[] paramKeyValue = param.split("=");
if (paramKeyValue.length > 1) {
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
}
}
}
headers.remove("original-url");
headers.remove("original-api-id");
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr = "";
if (method.equalsIgnoreCase("post")) {
StringBuilder sb = new StringBuilder();
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
} else {
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
}
response.setContentType("application/json");
response.getWriter().println(responseStr);
}
}
return paramMap;
}
/** 상태코드 + JSON 본문 응답. */
private void writeJson(ServletResponse response, int status, String json) throws IOException {
((HttpServletResponse) response).setStatus(status);
response.setContentType("application/json");
response.getWriter().println(json);
}
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
private String escapeJson(String s) {
if (s == null) {
return "";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\\': sb.append("\\\\"); break;
case '"': sb.append("\\\""); break;
case '\n': sb.append("\\n"); break;
case '\r': sb.append("\\r"); break;
case '\t': sb.append("\\t"); break;
default:
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
return sb.toString();
}
@Override
@@ -29,8 +29,15 @@ public class DjbTestbedGatewayProperty {
public static final String KEY_TOKEN_PATH = "djb.gateway.token-path";
public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header";
public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header";
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String DEFAULT_BASE_URL = "PortalMock";
public static final String DEFAULT_TIMEOUT_SEC = "10";
public static final String DEFAULT_USE_PROXY = "Y";
public static final String DEFAULT_TOKEN_USE_PROXY = "Y";
private static final int TIMEOUT_FALLBACK_MS = 10_000;
/** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */
public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token";
public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key";
@@ -60,6 +67,36 @@ public class DjbTestbedGatewayProperty {
"OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)");
}
/**
* 테스트베드 API 호출 시 서버 프록시({@code /api/call-api}) 사용 여부.
* {@code N} 이면 브라우저에서 대상 주소로 직접 호출(CORS 허용 필요). 기본 Y.
*/
public boolean useProxy() {
return !"N".equalsIgnoreCase(resolve(KEY_USE_PROXY, DEFAULT_USE_PROXY,
"테스트베드 API 호출 시 서버 프록시(/api/call-api) 사용 여부(Y/N). N 이면 브라우저에서 직접 호출."));
}
/**
* 토큰 발급 시 서버 프록시({@code /api/call-api}) 사용 여부. API 호출 프록시와 별개로 설정.
* {@code N} 이면 브라우저에서 토큰 URL 로 직접 호출(CORS 허용 필요). 기본 Y.
* (mock 모드는 포탈 자체 mock 토큰이라 프록시 유지가 자연스럽고, 실 GW 는 직접호출로 뺄 수 있음.)
*/
public boolean tokenUseProxy() {
return !"N".equalsIgnoreCase(resolve(KEY_TOKEN_USE_PROXY, DEFAULT_TOKEN_USE_PROXY,
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(Y/N). N 이면 브라우저에서 직접 호출."));
}
/** 테스트베드 프록시 연결/응답 타임아웃(ms). 미설정/파싱 실패 시 10초. (단위 저장: 초) */
public int timeoutMillis() {
try {
int sec = Integer.parseInt(resolve(KEY_TIMEOUT_SEC, DEFAULT_TIMEOUT_SEC,
"테스트베드 프록시 연결/응답 타임아웃(초)"));
return sec > 0 ? sec * 1000 : TIMEOUT_FALLBACK_MS;
} catch (Exception e) {
return TIMEOUT_FALLBACK_MS;
}
}
public DjbGatewayMode resolveGatewayMode() {
return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY;
}
@@ -76,6 +113,18 @@ public class DjbTestbedGatewayProperty {
return stripTrailingSlash(baseUrl()) + tokenPath();
}
/**
* GW 응답유형(spec responseType=gw)에서 사용할 API 호출 base URL.
* PortalMock → 요청 포탈 origin(mock 필터가 프록시), GATEWAY → {@code baseUrl}.
* (별도 {@code swagger.gw.address} 프로퍼티를 두지 않고 이 값을 단일 기준으로 사용한다.)
*/
public String resolveApiBaseUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin);
}
return stripTrailingSlash(baseUrl());
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
@@ -5,9 +5,11 @@ import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
@@ -22,8 +24,10 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* testbed spec(swagger.json)에 AUTHTYPE 기반 securityScheme 를 주입해 반환.
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(enrich 대상 외).
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
* 따른 실주소로 치환해 반환한다.
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
*/
@RestController
@RequestMapping("/djb/testbed/apis")
@@ -34,25 +38,38 @@ public class DjbTestbedSpecController {
private final ApiSpecInfoService apiSpecInfoService;
private final DjbTestbedAuthService authService;
private final DjbSwaggerSpecEnricher enricher;
private final DjbTestbedSpecServerRewriter serverRewriter;
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id) throws IOException {
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return ResponseEntity.ok(content);
return serverRewriter.rewriteServer(content, null, request);
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return ResponseEntity.notFound().build();
return null;
}
DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return ResponseEntity.ok(enriched);
return serverRewriter.rewriteServer(enriched, spec.get(), request);
}
}
@@ -7,15 +7,18 @@ import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeExcep
* <ul>
* <li>{@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}</li>
* <li>{@code api_key}/{@code apikey} → {@link #API_KEY}</li>
* <li>그 외 → {@link DjbUnsupportedAuthTypeException}</li>
* <li>{@code none}/{@code no}/{@code noauth} → {@link #NONE}(인증 없이 호출 — 앱 선택 불필요)</li>
* <li>{@code null}(EAIMessage 미존재) 및 그 외 → {@link DjbUnsupportedAuthTypeException}</li>
* </ul>
* ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.)
*/
public enum DjbAuthType {
OAUTH,
API_KEY;
API_KEY,
NONE;
public static DjbAuthType fromAuthtype(String authtype) {
// null(EAIMessage 미존재)은 데이터 문제 → 종전대로 예외. "none" 은 인증 없는 API.
if (authtype == null) {
throw new DjbUnsupportedAuthTypeException("null");
}
@@ -28,6 +31,10 @@ public enum DjbAuthType {
case "api_key":
case "apikey":
return API_KEY;
case "none":
case "no":
case "noauth":
return NONE;
default:
throw new DjbUnsupportedAuthTypeException(authtype);
}
@@ -43,6 +43,13 @@ public class DjbSwaggerSpecEnricher {
}
ObjectNode root = (ObjectNode) parsed;
// NONE: 인증 없이 호출 — 글로벌 security / securityScheme 흔적을 제거해
// Swagger UI 의 'Authorize' 버튼이 뜨지 않게 한다. (저장 spec 에 빈 securitySchemes 가
// 남아 있으면 UI 가 빈 인증 모달 버튼을 렌더하므로 정리한다.)
if (authType == DjbAuthType.NONE) {
return stripSecurity(root);
}
String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME;
ObjectNode scheme = buildApiKeyScheme(authType);
@@ -76,6 +83,17 @@ public class DjbSwaggerSpecEnricher {
return scheme;
}
/** NONE 용: 글로벌 {@code security} / {@code components.securitySchemes} / {@code securityDefinitions} 제거. */
private String stripSecurity(ObjectNode root) throws JsonProcessingException {
root.remove("security");
root.remove("securityDefinitions"); // swagger 2.0
JsonNode components = root.get("components");
if (components != null && components.isObject()) {
((ObjectNode) components).remove("securitySchemes");
}
return objectMapper.writeValueAsString(root);
}
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
JsonNode node = parent.get(field);
if (node != null && node.isObject()) {
@@ -0,0 +1,110 @@
package com.eactive.apim.portal.djb.testbed.service;
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
/**
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
*
* <ul>
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
* </ul>
*
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
*
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecServerRewriter {
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
public static final String SERVER_SENTINEL = "http://swagger-server-url";
private final DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper;
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(resolveBase(spec, request));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
public String toYaml(String specJson) {
try {
Object tree = objectMapper.readValue(specJson, Object.class);
DumperOptions opts = new DumperOptions();
opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
opts.setPrettyFlow(true);
return new Yaml(opts).dump(tree);
} catch (Exception e) {
log.error("spec YAML 변환 실패, JSON 원본 반환", e);
return specJson;
}
}
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
? "sample" : spec.getResponseType().trim();
if ("gw".equalsIgnoreCase(rt)) {
return gatewayProperty.resolveApiBaseUrl(originOf(request));
}
if ("mock".equalsIgnoreCase(rt)) {
String mock = (spec == null) ? null : spec.getMockUrl();
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
}
// sample(기본): 포탈 origin
return originOf(request);
}
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
public static String originOf(HttpServletRequest request) {
if (request == null) {
return "";
}
String scheme = firstNonBlank(request.getHeader("X-Forwarded-Proto"), request.getScheme());
String host = firstNonBlank(request.getHeader("X-Forwarded-Host"), request.getHeader("Host"));
if (StringUtils.hasText(host)) {
return scheme + "://" + host.trim();
}
int port = request.getServerPort();
String hostPort = request.getServerName() + ((port == 80 || port == 443) ? "" : ":" + port);
return scheme + "://" + hostPort;
}
private static String firstNonBlank(String a, String b) {
return StringUtils.hasText(a) ? a.trim() : b;
}
private static String stripTrailingSlash(String s) {
if (s == null) {
return null;
}
String t = s.trim();
while (t.endsWith("/")) {
t = t.substring(0, t.length() - 1);
}
return t;
}
}
@@ -49,6 +49,67 @@
catch (e) { return null; }
}
// window.ui 에서 해석된 spec(json) 조회. (snippet-panel 과 동일 방식)
function getSpecJson() {
try {
var ui = global.ui;
if (ui && typeof ui.spec === "function") {
var s = ui.spec();
if (s && typeof s.toJS === "function") {
var o = s.toJS();
return o.json || o;
}
}
} catch (e) { /* noop */ }
return null;
}
// 단일 API 페이지 전제(첫 path/method)로 operation 반환.
function getSingleOperation(spec) {
var paths = (spec && spec.paths) ? spec.paths : {};
var pk = Object.keys(paths)[0];
if (!pk) return null;
var ops = paths[pk];
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
if (!mk) return null;
return { path: pk, method: mk, operation: ops[mk] };
}
// opblock 안 파라미터 입력값(header/query) 읽기.
function readParamInput(opblock, name, where) {
var scope = opblock || document;
var row = scope.querySelector('tr[data-param-name="' + name + '"][data-param-in="' + where + '"]');
if (row) {
var input = row.querySelector('input[type="text"], input:not([type]), textarea');
if (input && input.value) return input.value;
}
return undefined;
}
// Execute 직전 상태에서 검증용 요청 객체를 조립(헤더/쿼리/본문).
function buildValidationRequest(opblock, opInfo) {
var op = opInfo.operation;
var headers = {};
var query = [];
(op.parameters || []).forEach(function (p) {
if (p.in === "header") {
var hv = readParamInput(opblock, p.name, "header");
if (hv !== undefined) headers[p.name] = hv;
} else if (p.in === "query") {
var qv = readParamInput(opblock, p.name, "query");
if (qv !== undefined) query.push(encodeURIComponent(p.name) + "=" + encodeURIComponent(qv));
}
});
var body = "";
if (op.requestBody) {
headers["Content-Type"] = headers["Content-Type"] || "application/json";
var ta = (opblock || document).querySelector("textarea.body-param__text");
if (ta) body = ta.value;
}
var url = "http://_local" + opInfo.path + (query.length ? "?" + query.join("&") : "");
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
}
function renderEmpty() {
return ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
@@ -119,13 +180,75 @@
return grid;
}
// Execute 클릭 시점에 대상 opblock 확정 + 타이머 시작 + 빈 그리드 표시.
// 검증 실패 렌더: 네트워크 요청이 발생하지 않은 상태. djb-error 스타일 그리드.
function renderValidationErrors(errors) {
var grid = gridFor(targetOpblock);
if (!grid) return;
var items = (errors || []).map(function (er) {
return '<li class="verr-item">'
+ '<span class="verr-path">' + escapeHtml(er.path) + '</span> '
+ '<span class="verr-msg">' + escapeHtml(er.message) + '</span>'
+ (er.value !== undefined ? ' <code class="verr-val mono">' + escapeHtml(JSON.stringify(er.value)) + '</code>' : "")
+ '</li>';
}).join("");
grid.classList.remove("djb-empty");
grid.classList.add("djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">검증 실패 — 요청이 전송되지 않았습니다.</div></div>'
+ '<div class="cell-body"><div class="cell-title">검증 실패 (' + (errors || []).length + '건)</div>'
+ '<ul class="verr-list">' + items + '</ul></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip err">검증실패</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
function renderNetworkError(msg) {
var grid = gridFor(targetOpblock);
if (!grid) return;
grid.classList.remove("djb-empty");
grid.classList.add("djb-error");
grid.innerHTML = ''
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
+ '<div class="empty">응답을 받지 못했습니다.</div></div>'
+ '<div class="cell-body"><div class="cell-title">네트워크 오류</div>'
+ '<pre class="body-panel mono active" data-tab="raw">요청 실패: ' + escapeHtml(msg) + '</pre></div>'
+ '<div class="cell-meta">'
+ '<span class="status-chip err">네트워크 오류</span>'
+ '<span class="time">Response Time: -</span>'
+ '</div>';
}
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
function onExecuteClick(e) {
var t = e.target;
if (!t || !t.closest) return;
var btn = t.closest(".btn.execute");
if (!btn) return;
targetOpblock = btn.closest(".opblock");
// ── 요청 파라미터/본문 검증 (검증기 로드된 경우) ──
if (global.DjbSwaggerValidator) {
try {
var spec = getSpecJson();
var opInfo = spec && getSingleOperation(spec);
if (opInfo) {
var req = buildValidationRequest(targetOpblock, opInfo);
var result = global.DjbSwaggerValidator.validateOperation(opInfo.operation, req, spec);
if (!result.ok) {
e.preventDefault();
e.stopImmediatePropagation();
renderValidationErrors(result.errors);
return;
}
}
} catch (err) { console.error("djb validation error:", err); }
}
startTs = now();
var grid = gridFor(targetOpblock);
if (grid) {
@@ -174,14 +297,54 @@
}, 0);
}
// fetch 실패(네트워크/CORS/타임아웃)는 responseInterceptor 로 전달되지 않고 store 의
// spec.responses[path][method] 에 { error:true, err } 로만 저장된다. store 를 구독해
// 사용자에게 네트워크 오류 그리드로 보여준다. (PoC bootstrap 의 getStore().subscribe 이식)
function watchNetworkErrors(ui) {
if (!ui || typeof ui.getStore !== "function" || ui.__djbNetWatch) return;
ui.__djbNetWatch = true;
var rendered = {};
ui.getStore().subscribe(function () {
try {
var respMap = ui.getState().getIn(["spec", "responses"]);
if (!respMap || typeof respMap.forEach !== "function") return;
respMap.forEach(function (methods, path) {
if (!methods || typeof methods.forEach !== "function") return;
methods.forEach(function (resp, method) {
var key = path + " " + method;
var hasError = resp && (resp.get ? resp.get("error") : resp.error);
if (hasError) {
if (rendered[key]) return;
rendered[key] = true;
var err = resp.get ? resp.get("err") : resp.err;
var msg = (err && (err.message || (err.get && err.get("message"))))
|| (err ? String(err) : "응답을 받지 못했습니다.");
renderNetworkError(String(msg));
} else {
rendered[key] = false;
}
});
});
} catch (e) { /* ignore */ }
});
}
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
function attach(rootSel) {
var root = document.querySelector(rootSel || "#swagger-ui");
if (!root || root.__djbRespBound) return;
root.__djbRespBound = true;
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보 + 검증 차단
root.addEventListener("click", onExecuteClick, true);
// window.ui 준비 시 네트워크 오류 감시 등록
watchNetworkErrors(global.ui);
}
global.DjbSwaggerResponse = { attach: attach, renderResponse: renderResponse };
global.DjbSwaggerResponse = {
attach: attach,
renderResponse: renderResponse,
renderValidationErrors: renderValidationErrors,
renderNetworkError: renderNetworkError,
watchNetworkErrors: watchNetworkErrors
};
})(window);
@@ -174,15 +174,29 @@
}
}
// Execute 버튼(.btn-group) 바로 아래에 패널 확보.
// - operation 이 접혀 opblock-body/실행영역이 없으면: 잘못 남은 패널을 제거하고 중단
// (bare .opblock 에 붙어 opblock-summary 뒤로 튀는 문제 방지). 다음 펼침 때 재생성.
// Execute 영역 바로 아래 = Swagger UI 폼 하단에 패널 확보.
// - 앵커는 opblock-body '직속' 자식만 본다: 실행 전에는 .execute-wrapper, 실행 후에는
// Swagger UI 5 가 이를 .btn-group(실행+초기화 버튼군)으로 '교체'하므로 둘 다 허용한다.
// (execute-wrapper 만 앵커로 쓰면 실행 후 앵커 소실 → 패널이 제거되고 재생성 안 됨.)
// - 중첩 .btn-group(파라미터 섹션 헤더의 try-out '취소' 버튼군)은 직속 자식이 아니라
// 건너뛴다: 그걸 앵커로 잡으면 flex 헤더 안에 삽입돼 "파라미터" 라벨이 찌부된다.
// - operation 이 접혀 opblock-body/Execute 영역이 없으면: 잘못 남은 패널을 제거하고 중단.
// 다음 펼침 때 재생성.
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
function ensureMounted() {
var op = document.querySelector(rootSel + " .opblock.is-open")
|| document.querySelector(rootSel + " .opblock");
var body = op && op.querySelector(".opblock-body");
var anchor = body && (body.querySelector(".btn-group") || body.querySelector(".execute-wrapper"));
var anchor = null;
if (body) {
for (var i = 0; i < body.children.length; i++) {
var kid = body.children[i];
if (kid.classList.contains("execute-wrapper") || kid.classList.contains("btn-group")) {
anchor = kid;
break;
}
}
}
var existing = panelEl();
if (!body || !anchor) {
@@ -29,13 +29,17 @@
--djb-accent: #0969da;
}
/* ─── Swagger UI 기본 응답영역 통째 숨김 ──────────────────────────────────────
요청 스니펫은 내장(.request-snippets, 실행 후·응답영역 내부·밝은 테마) 대신
상시 다크 패널(#djb-snippet-panel, djb-swagger-snippet-panel.js)로 대체하고,
응답 결과는 커스텀 그리드(.djb-response-grid)로 대체한다.
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 그리드. */
.swagger-ui .responses-wrapper { display: none !important; }
.swagger-ui .request-snippets { display: none !important; }
/* ─── Swagger UI 기본 응답영역: 문서화 응답 샘플만 노출 ────────────────────────
응답 영역(.responses-wrapper)은 표시하되(= 상태코드별 응답 예제/스키마 = "응답 샘플"),
실행 결과 라이브 블록은 숨긴다:
- 요청 스니펫(.request-snippets) → 상시 다크 패널(#djb-snippet-panel)로 대체
- 라이브 응답(Curl/Request URL/Server response + .live-responses-table)
= .responses-inner 의 직접 자식 <div> → 커스텀 그리드(.djb-response-grid)로 대체
문서화 응답 예제는 .responses-inner > table.responses-table 이라 <div> 숨김과 구분됨.
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 샘플(표) → 응답 그리드. */
.swagger-ui .request-snippets { display: none !important; }
.swagger-ui .responses-inner > div { display: none !important; }
.swagger-ui .live-responses-table { display: none !important; }
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
.swagger-ui #djb-snippet-panel {
@@ -265,6 +269,39 @@
color: var(--djb-text-muted);
}
/* ─── 검증 실패 목록 (djb-swagger-validator.js) ─────────────────────────────── */
.swagger-ui .djb-response-grid .verr-list {
list-style: none;
margin: 0;
padding: 0;
}
.swagger-ui .djb-response-grid .verr-item {
padding: 8px 10px;
margin-bottom: 6px;
background: #fff8f8;
border: 1px solid #ffd7d5;
border-left: 3px solid var(--djb-err);
border-radius: 4px;
font-size: 13px;
line-height: 1.5;
}
.swagger-ui .djb-response-grid .verr-path {
font-family: var(--djb-mono);
font-weight: 700;
color: var(--djb-err);
}
.swagger-ui .djb-response-grid .verr-msg { color: var(--djb-text); }
.swagger-ui .djb-response-grid .verr-val {
display: inline-block;
margin-top: 2px;
padding: 1px 6px;
background: var(--djb-bg-alt);
border: 1px solid var(--djb-border);
border-radius: 3px;
color: var(--djb-text-muted);
font-size: 12px;
}
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
@@ -0,0 +1,202 @@
/*!
* DjbSwaggerValidator — Execute 전에 요청 파라미터(header/query)와 요청 본문(body)을
* OpenAPI 스키마로 검증하는 경량 JSON Schema 검증기.
* (PoC rinjae-works/swagger-poc/js/validator.js 를 포탈용으로 이식)
*
* 지원 키워드: type, required, properties, items, $ref, maxLength, minLength,
* maximum, minimum, pattern, enum
* 지원 type: string, integer, number, object, array, boolean
*
* 사용:
* var r = DjbSwaggerValidator.validateOperation(operation, parsedRequest, rootSpec);
* if (!r.ok) console.log(r.errors); // [{ path, message, value }]
*/
(function (global) {
"use strict";
function resolveRef(spec, ref) {
if (ref.indexOf("#/") !== 0) return null;
var parts = ref.slice(2).split("/");
var cur = spec;
for (var i = 0; i < parts.length; i++) {
if (cur == null) return null;
cur = cur[decodeURIComponent(parts[i]).replace(/~1/g, "/").replace(/~0/g, "~")];
}
return cur || null;
}
function deref(spec, schema, seen) {
if (!schema || typeof schema !== "object") return schema;
if (schema.$ref) {
if (seen && seen.indexOf(schema.$ref) !== -1) return {};
var next = resolveRef(spec, schema.$ref);
var seen2 = (seen || []).concat([schema.$ref]);
return deref(spec, next, seen2);
}
return schema;
}
function typeMatches(value, type) {
if (value === null || value === undefined) return false;
switch (type) {
case "string": return typeof value === "string";
case "integer": return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
case "number": return typeof value === "number" && !isNaN(value);
case "boolean": return typeof value === "boolean";
case "array": return Array.isArray(value);
case "object": return typeof value === "object" && !Array.isArray(value);
default: return true;
}
}
function pushErr(errors, path, message, value) {
errors.push({ path: path || "/", message: message, value: value });
}
function checkSchema(spec, schema, value, path, errors) {
if (!schema) return;
schema = deref(spec, schema);
if (value === undefined || value === null) {
// required 는 상위 object 단계에서 확인. 여기서는 통과.
return;
}
if (schema.type && !typeMatches(value, schema.type)) {
var actual = typeof value === "object" ? (Array.isArray(value) ? "array" : "object") : typeof value;
pushErr(errors, path, '타입이 "' + schema.type + '" 이어야 합니다 (현재: ' + actual + ")", value);
return;
}
if (Array.isArray(schema.enum) && schema.enum.indexOf(value) === -1) {
pushErr(errors, path, "허용된 값이 아닙니다 (허용: " + schema.enum.join(", ") + ")", value);
}
if (schema.type === "string" || typeof value === "string") {
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
pushErr(errors, path, "최대 " + schema.maxLength + " 자까지 허용됩니다 (현재: " + value.length + "자)", value);
}
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
pushErr(errors, path, "최소 " + schema.minLength + " 자 이상이어야 합니다 (현재: " + value.length + "자)", value);
}
if (schema.pattern) {
try {
if (!new RegExp(schema.pattern).test(value)) {
pushErr(errors, path, "형식이 올바르지 않습니다 (pattern: " + schema.pattern + ")", value);
}
} catch (e) { /* invalid regex in spec — skip */ }
}
}
if (schema.type === "integer" || schema.type === "number" || typeof value === "number") {
if (typeof schema.maximum === "number" && value > schema.maximum) {
pushErr(errors, path, "최대값 " + schema.maximum + " 이하여야 합니다 (현재: " + value + ")", value);
}
if (typeof schema.minimum === "number" && value < schema.minimum) {
pushErr(errors, path, "최소값 " + schema.minimum + " 이상이어야 합니다 (현재: " + value + ")", value);
}
}
if (schema.type === "object" && schema.properties) {
var requiredList = Array.isArray(schema.required) ? schema.required : [];
requiredList.forEach(function (reqKey) {
if (value[reqKey] === undefined || value[reqKey] === null || value[reqKey] === "") {
pushErr(errors, path + "/" + reqKey, "필수 항목이 누락되었습니다", undefined);
}
});
Object.keys(schema.properties).forEach(function (key) {
if (value[key] !== undefined && value[key] !== null) {
checkSchema(spec, schema.properties[key], value[key], path + "/" + key, errors);
}
});
}
if (schema.type === "array" && schema.items && Array.isArray(value)) {
value.forEach(function (item, idx) {
checkSchema(spec, schema.items, item, path + "/" + idx, errors);
});
}
}
/**
* Swagger UI 의 request 객체에서 header/query/body 를 추출해 operation 정의로 검증.
*
* @param {Object} operation spec.paths[path][method] 객체
* @param {Object} req { url, method, headers, body, ... }
* @param {Object} rootSpec $ref 해석을 위한 전체 OpenAPI 문서(json)
* @returns {{ ok:boolean, errors:Array<{path,message,value}> }}
*/
function validateOperation(operation, req, rootSpec) {
var errors = [];
if (!operation) return { ok: true, errors: errors };
// 1. parameters 검증 (header / query). path 는 Swagger 가 이미 치환 → 건너뜀.
var params = Array.isArray(operation.parameters) ? operation.parameters : [];
for (var i = 0; i < params.length; i++) {
var p = deref(rootSpec, params[i]);
var where = p.in;
var name = p.name;
var schema = p.schema ? deref(rootSpec, p.schema) : { type: "string" };
var value;
if (where === "header") {
var headers = req.headers || {};
var matchKey = Object.keys(headers).filter(function (k) {
return k.toLowerCase() === String(name).toLowerCase();
})[0];
value = matchKey ? headers[matchKey] : undefined;
} else if (where === "query") {
try {
var u = new URL(req.url, "http://_local");
value = u.searchParams.get(name);
} catch (e) { value = undefined; }
} else {
continue; // path/cookie 등은 건너뜀
}
if (p.required && (value === undefined || value === "" || value === null)) {
pushErr(errors, (where === "query" ? "query" : "header") + "[" + name + "]",
(where === "query" ? "필수 쿼리 파라미터가" : "필수 헤더가") + " 누락되었습니다", undefined);
continue;
}
if (value !== undefined && value !== null && value !== "") {
var coerced = (schema.type === "integer" || schema.type === "number") ? Number(value) : String(value);
checkSchema(rootSpec, schema, coerced, (where === "query" ? "query" : "header") + "[" + name + "]", errors);
}
}
// 2. requestBody 검증
if (operation.requestBody) {
var rb = deref(rootSpec, operation.requestBody);
if (rb.required && (req.body === undefined || req.body === null || req.body === "")) {
pushErr(errors, "body", "요청 본문이 비어 있습니다", undefined);
} else if (req.body) {
var contentTypes = Object.keys(rb.content || {});
var ctHeader = (req.headers && (req.headers["Content-Type"] || req.headers["content-type"])) || contentTypes[0];
var matchedCt = contentTypes.filter(function (c) {
return ctHeader && ctHeader.toLowerCase().indexOf(c.split(";")[0].toLowerCase()) !== -1;
})[0] || contentTypes[0];
var bodySchema = (matchedCt && rb.content[matchedCt]) ? deref(rootSpec, rb.content[matchedCt].schema) : null;
var parsed = req.body;
if (typeof parsed === "string") {
try { parsed = JSON.parse(parsed); }
catch (e) {
pushErr(errors, "body", "JSON 파싱 실패: " + e.message, req.body);
return { ok: false, errors: errors };
}
}
if (bodySchema) {
checkSchema(rootSpec, bodySchema, parsed, "body", errors);
}
}
}
return { ok: errors.length === 0, errors: errors };
}
global.DjbSwaggerValidator = {
validateOperation: validateOperation,
_internal: { resolveRef: resolveRef, deref: deref, checkSchema: checkSchema }
};
})(window);
@@ -326,18 +326,25 @@
return Promise.all([
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-snippets.js}]]*/ ''),
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-snippet-panel.js}]]*/ ''),
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-validator.js}]]*/ ''),
loadScript(/*[[@{/plugins/swaggerUI/djb-swagger-response.js}]]*/ '')
]);
}).then(() => {
// Clear existing Swagger UI
document.getElementById('swagger-ui').innerHTML = '';
// 테스트베드 프록시 사용 여부(djb.gateway.use-proxy). N 이면 브라우저에서 대상으로 직접 호출.
window.__djbUseProxy = /*[[${@djbTestbedGatewayProperty.useProxy()}]]*/ true;
// 토큰 발급 프록시 사용 여부(djb.gateway.token-use-proxy) — API 호출과 별개.
window.__djbTokenUseProxy = /*[[${@djbTestbedGatewayProperty.tokenUseProxy()}]]*/ true;
// Initialize Swagger UI
const ui = SwaggerUIBundle({
url: swaggerUrl,
dom_id: '#swagger-ui',
requestInterceptor: function(req) {
if (!req.loadSpec) {
// 프록시 사용 시에만 /api/call-api 로 우회. 직접호출 모드(false)면 req.url(스펙 서버+경로) 그대로 호출.
if (!req.loadSpec && window.__djbUseProxy !== false) {
const urlPath = new URL(req.url).pathname;
const method = req.method.toLowerCase();
@@ -460,13 +467,17 @@
+ '&client_id=' + encodeURIComponent(clientId)
+ '&client_secret=' + encodeURIComponent(clientSecret)
+ '&scope=api';
return fetch(window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api'), {
// 토큰 발급 프록시 여부(djb.gateway.token-use-proxy). false 면 토큰 URL 로 브라우저 직접 호출.
var direct = (window.__djbTokenUseProxy === false);
var url = direct ? gw.tokenUrl : window.location.origin + (/*[[@{/api/call-api}]]*/ '/api/call-api');
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
if (!direct) {
headers['original-url'] = gw.tokenUrl;
headers['X-XSRF-TOKEN'] = /*[[${_csrf.token}]]*/ '';
}
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'original-url': gw.tokenUrl,
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ ''
},
headers: headers,
body: body
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (data) { return data ? data.access_token : null; });
@@ -46,6 +46,7 @@
<!-- DJB 요청 스니펫(클라이언트 7종) / 상시 스니펫 패널 / 커스텀 응답 그리드 -->
<script th:src="@{/plugins/swaggerUI/djb-swagger-snippets.js}" charset="UTF-8"></script>
<script th:src="@{/plugins/swaggerUI/djb-swagger-snippet-panel.js}" charset="UTF-8"></script>
<script th:src="@{/plugins/swaggerUI/djb-swagger-validator.js}" charset="UTF-8"></script>
<script th:src="@{/plugins/swaggerUI/djb-swagger-response.js}" charset="UTF-8"></script>
<script>
@@ -57,12 +58,15 @@
const swaggerUrl = `/api/apis/${apiId}/swagger.json`;
// Clear existing Swagger UI
document.getElementById('swagger-ui').innerHTML = '';
// 테스트베드 프록시 사용 여부(djb.gateway.use-proxy). N 이면 브라우저에서 대상으로 직접 호출.
window.__djbUseProxy = ('[[${@djbTestbedGatewayProperty.useProxy()}]]' !== 'false');
// Initialize Swagger UI
const ui = SwaggerUIBundle({
url: swaggerUrl,
dom_id: '#swagger-ui',
requestInterceptor: function(req) {
if (!req.loadSpec) {
// 프록시 사용 시에만 /api/call-api 로 우회. 직접호출 모드면 req.url 그대로 호출.
if (!req.loadSpec && window.__djbUseProxy !== false) {
const urlPath = new URL(req.url).pathname;
const method = req.method.toLowerCase();
@@ -62,6 +62,24 @@ class DjbSwaggerSpecEnricherTest {
assertTrue(root.path("security").get(0).has("djbApiKey"));
}
@Test
void none_strips_security_and_schemes_but_preserves_paths() throws Exception {
// 저장 spec 에 빈 securitySchemes + 글로벌 security 가 남아 있어도 NONE 은 제거한다.
String spec = "{\"openapi\":\"3.0.0\",\"security\":[{\"djbOAuth\":[]}],"
+ "\"components\":{\"securitySchemes\":{},\"schemas\":{\"X\":{}}},"
+ "\"paths\":{\"/a\":{\"get\":{\"x-original-api-id\":\"API1\"}}}}";
String result = enricher.enrich(spec, DjbAuthType.NONE);
JsonNode root = objectMapper.readTree(result);
// 인증 흔적 제거 → Swagger UI Authorize 버튼 미표시.
assertFalse(root.has("security"));
assertFalse(root.path("components").has("securitySchemes"));
// 그 외(components.schemas, paths, x- 확장)는 보존.
assertTrue(root.path("components").has("schemas"));
assertEquals("API1", root.path("paths").path("/a").path("get").path("x-original-api-id").asText());
}
@Test
void swagger2_falls_back_to_securityDefinitions() throws Exception {
when(gatewayProperty.apiKeyHeader()).thenReturn("X-DJB-API-Key");
@@ -187,6 +187,15 @@ class DjbTestbedAuthServiceTest {
assertEquals(DjbAuthType.API_KEY, service.resolveAuthType(API_ID));
}
@Test
void resolveAuthType_none_returnsNone() {
EAIMessageEntity msg = Mockito.mock(EAIMessageEntity.class);
when(msg.getAuthtype()).thenReturn("none");
when(eaiMessageRepository.findById(API_ID)).thenReturn(Optional.of(msg));
assertEquals(DjbAuthType.NONE, service.resolveAuthType(API_ID));
}
@Test
void resolveAuthType_missing_throwsUnsupported() {
when(eaiMessageRepository.findById("APIX")).thenReturn(Optional.empty());