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:
+25
-15
@@ -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
|
||||
|
||||
+49
@@ -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;
|
||||
|
||||
+23
-6
@@ -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()) {
|
||||
|
||||
+110
@@ -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();
|
||||
|
||||
|
||||
+18
@@ -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");
|
||||
|
||||
+9
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user