diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java b/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java index 4c1c670..b4a403b 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java +++ b/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java @@ -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 getSwagger(@PathVariable String id) { + public ResponseEntity 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 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 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); } } diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java b/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java index 0a3874a..1673573 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java +++ b/src/main/java/com/eactive/apim/portal/apps/apis/filter/APISender.java @@ -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 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 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"); diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java index ec4ab6c..f6df2ba 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java +++ b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterFilter.java @@ -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 headers = new HashMap<>(); + Enumeration 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 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 headerNames = httpServletRequest.getHeaderNames(); + // mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지) + targetUri = apiSpecInfoDto.getMockUrl(); + paramMap = extractQueryParams(url); + } - Map 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 extractQueryParams(String originalUrl) { + Map 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 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 diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java b/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java index 132332b..dc6dbee 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java @@ -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; diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java b/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java index 824d676..0bc4786 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/controller/DjbTestbedSpecController.java @@ -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 swaggerWithAuth(@PathVariable String id) throws IOException { + public ResponseEntity 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 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 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); } } diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java b/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java index bc841cd..4bcffd1 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/enums/DjbAuthType.java @@ -7,15 +7,18 @@ import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeExcep *
    *
  • {@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}
  • *
  • {@code api_key}/{@code apikey} → {@link #API_KEY}
  • - *
  • 그 외 → {@link DjbUnsupportedAuthTypeException}
  • + *
  • {@code none}/{@code no}/{@code noauth} → {@link #NONE}(인증 없이 호출 — 앱 선택 불필요)
  • + *
  • {@code null}(EAIMessage 미존재) 및 그 외 → {@link DjbUnsupportedAuthTypeException}
  • *
* ({@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); } diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java index c29ff17..12641f6 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbSwaggerSpecEnricher.java @@ -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()) { diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java new file mode 100644 index 0000000..a96a3a4 --- /dev/null +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java @@ -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)에 따른 실주소로 치환한다. + * + *
    + *
  • gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)
  • + *
  • mock : {@code ApiSpecInfo.mockUrl}
  • + *
  • sample(기본) : 요청 포탈 origin(scheme://host)
  • + *
+ * + *

spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시). + * admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며, + * 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다. + * + *

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; + } +} diff --git a/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js b/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js index ffd2f47..574ffce 100644 --- a/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js +++ b/src/main/resources/static/plugins/swaggerUI/djb-swagger-response.js @@ -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 '' + '

응답 헤더
' @@ -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 '
  • ' + + '' + escapeHtml(er.path) + ' ' + + '' + escapeHtml(er.message) + '' + + (er.value !== undefined ? ' ' + escapeHtml(JSON.stringify(er.value)) + '' : "") + + '
  • '; + }).join(""); + grid.classList.remove("djb-empty"); + grid.classList.add("djb-error"); + grid.innerHTML = '' + + '
    응답 헤더
    ' + + '
    검증 실패 — 요청이 전송되지 않았습니다.
    ' + + '
    검증 실패 (' + (errors || []).length + '건)
    ' + + '
      ' + items + '
    ' + + '
    ' + + '검증실패' + + 'Response Time: -' + + '
    '; + } + + // 네트워크/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 = '' + + '
    응답 헤더
    ' + + '
    응답을 받지 못했습니다.
    ' + + '
    네트워크 오류
    ' + + '
    요청 실패: ' + escapeHtml(msg) + '
    ' + + '
    ' + + '네트워크 오류' + + 'Response Time: -' + + '
    '; + } + + // 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); diff --git a/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js b/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js index 2064071..e0a0647 100644 --- a/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js +++ b/src/main/resources/static/plugins/swaggerUI/djb-swagger-snippet-panel.js @@ -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) { diff --git a/src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css b/src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css index aea7691..fbff94a 100644 --- a/src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css +++ b/src/main/resources/static/plugins/swaggerUI/djb-swagger-testbed.css @@ -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 의 직접 자식
    → 커스텀 그리드(.djb-response-grid)로 대체 + 문서화 응답 예제는 .responses-inner > table.responses-table 이라
    숨김과 구분됨. + 최종 순서: 파라미터 → 요청 본문 → 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)과 겹쳐 선택한 글자가 안 보이므로, 선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */ diff --git a/src/main/resources/static/plugins/swaggerUI/djb-swagger-validator.js b/src/main/resources/static/plugins/swaggerUI/djb-swagger-validator.js new file mode 100644 index 0000000..289e57e --- /dev/null +++ b/src/main/resources/static/plugins/swaggerUI/djb-swagger-validator.js @@ -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); diff --git a/src/main/resources/templates/views/apps/apis/mainApiDetail.html b/src/main/resources/templates/views/apps/apis/mainApiDetail.html index c88b1c3..1cdd60d 100644 --- a/src/main/resources/templates/views/apps/apis/mainApiDetail.html +++ b/src/main/resources/templates/views/apps/apis/mainApiDetail.html @@ -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; }); diff --git a/src/main/resources/templates/views/apps/apis/mainTestbed.html b/src/main/resources/templates/views/apps/apis/mainTestbed.html index c51ab65..84d29e7 100644 --- a/src/main/resources/templates/views/apps/apis/mainTestbed.html +++ b/src/main/resources/templates/views/apps/apis/mainTestbed.html @@ -46,6 +46,7 @@ +