From 2a9980ab16c6253080a27ed37f66a82c7874fe65 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Tue, 11 Aug 2026 16:38:46 +0900 Subject: [PATCH] =?UTF-8?q?-=20API=20Tester=20=ED=94=84=EB=A1=9D=EC=8B=9C?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20=ED=97=A4=EB=8D=94=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20-=20BLOCKED=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=B6=94=EA=B0=80=20-=20API=20ID=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=AA=85=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20-=20GW/Mock=20path=20?= =?UTF-8?q?=EB=A7=A4=EC=B9=AD=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?-=20APISender=EC=97=90=20ApiResponse=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=8F=84=EC=9E=85=20-=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=BD=94=EB=93=9C/=EB=B3=B8=EB=AC=B8/?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../portal/apps/apis/filter/APISender.java | 123 +++++++++++++++--- .../apps/apis/filter/ApiTesterFilter.java | 103 +++++++++++++-- .../apps/apis/mapper/ApiSpecMapper.java | 8 +- .../controller/DjbTestbedSpecController.java | 10 +- .../service/DjbSwaggerSpecEnricher.java | 43 ++++++ 5 files changed, 254 insertions(+), 33 deletions(-) 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 f511e67..37635f9 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 @@ -12,6 +12,9 @@ import java.net.HttpURLConnection; import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -22,6 +25,57 @@ public class APISender { private static final Logger logger = LoggerFactory.getLogger(APISender.class); + /** 응답 charset 을 Content-Type 에서 얻지 못할 때 사용할 기본값. */ + private static final String DEFAULT_CHARSET = "UTF-8"; + + /** + * 프록시 대상(GW/mock) 응답을 상태코드까지 포함해 전달하기 위한 홀더. + * + *

본문만 반환하면 대상이 4xx/5xx 를 내려도 호출측이 200 으로 되돌려주게 되므로 + * 상태코드와 Content-Type 을 함께 담는다.

+ */ + public static class ApiResponse { + + private final int status; + private final String body; + private final String contentType; + private final Map> headers; + + public ApiResponse(int status, String body, String contentType, Map> headers) { + this.status = status; + this.body = body; + this.contentType = contentType; + this.headers = headers == null + ? Collections.>emptyMap() + : Collections.unmodifiableMap(headers); + } + + public int getStatus() { + return status; + } + + public String getBody() { + return body; + } + + /** 대상 응답의 Content-Type 원본 (없으면 null). */ + public String getContentType() { + return contentType; + } + + /** 대상 응답 헤더 (상태줄 의사헤더인 null 키는 제외). 값은 헤더당 복수 가능. */ + public Map> getHeaders() { + return headers; + } + + @Override + public String toString() { + return "ApiResponse{status=" + status + ", contentType=" + contentType + + ", bodyLen=" + (body == null ? 0 : body.length()) + + ", headers=" + headers.keySet() + "}"; + } + } + // 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준. private final DjbTestbedGatewayProperty gatewayProperty; @@ -36,7 +90,7 @@ public class APISender { connection.setReadTimeout(t); } - public String requestPost(String uri, String requestBody) throws IOException { + public ApiResponse requestPost(String uri, String requestBody) throws IOException { if (logger.isDebugEnabled()) { logger.debug("APISender POST(json) 요청 - uri={}, bodyLen={}, body={}", @@ -45,11 +99,11 @@ public class APISender { HttpURLConnection connection = getHttpURLConnection(uri, requestBody); - String response = getResponse(connection); + ApiResponse response = getResponse(connection); connection.disconnect(); if (logger.isDebugEnabled()) { - logger.debug("APISender POST(json) 응답 - uri={}, response={}", uri, response); + logger.debug("APISender POST(json) 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody()); } return response; } @@ -75,7 +129,7 @@ public class APISender { return uriBuilder.toString(); } - public String requestGet(String uri, Map headers, Map params) throws IOException { + public ApiResponse requestGet(String uri, Map headers, Map params) throws IOException { URL endpoint = new URL(appendUriAndParams(uri, params)); HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection(); @@ -95,16 +149,16 @@ public class APISender { logger.debug("APISender GET 요청 - uri={}", appendUriAndParams(uri, params)); } - String response = getResponse(connection); + ApiResponse response = getResponse(connection); connection.disconnect(); if (logger.isDebugEnabled()) { - logger.debug("APISender GET 응답 - uri={}, response={}", uri, response); + logger.debug("APISender GET 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody()); } return response; } - public String requestPost(String uri, Map headers, Map params, String requestBody) throws IOException { + public ApiResponse requestPost(String uri, Map headers, Map params, String requestBody) throws IOException { URL endpoint = new URL(appendUriAndParams(uri, params)); HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection(); @@ -132,30 +186,67 @@ public class APISender { outputStream.flush(); } - String response = getResponse(connection); + ApiResponse response = getResponse(connection); connection.disconnect(); if (logger.isDebugEnabled()) { - logger.debug("APISender POST 응답 - uri={}, response={}", uri, response); + logger.debug("APISender POST 응답 - uri={}, status={}, response={}", uri, response.getStatus(), response.getBody()); } return response; } - private static String getResponse(HttpURLConnection connection) throws IOException { + /** + * 대상 응답을 상태코드·Content-Type·본문으로 읽는다. + * + *

4xx/5xx 는 {@code getInputStream()} 이 IOException 을 던지므로 errorStream 으로 본문을 읽고, + * 본문이 아예 없는 응답(errorStream == null)은 빈 문자열로 처리한다.

+ */ + private static ApiResponse getResponse(HttpURLConnection connection) throws IOException { int responseCode = connection.getResponseCode(); + String contentType = connection.getContentType(); StringBuilder response = new StringBuilder(); - try (InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream(); InputStreamReader isr = new InputStreamReader(stream); - BufferedReader reader = new BufferedReader(isr)) { + InputStream stream = (responseCode < 400) ? connection.getInputStream() : connection.getErrorStream(); + if (stream != null) { + try (InputStreamReader isr = new InputStreamReader(stream, charsetOf(contentType)); + BufferedReader reader = new BufferedReader(isr)) { - String line; - while ((line = reader.readLine()) != null) { - response.append(line); + String line; + while ((line = reader.readLine()) != null) { + response.append(line); + } } } - return response.toString(); + return new ApiResponse(responseCode, response.toString(), contentType, copyHeaders(connection)); + } + + /** 대상 응답 헤더 복사. {@code getHeaderFields()} 의 null 키(상태줄)는 제외. */ + private static Map> copyHeaders(HttpURLConnection connection) { + Map> headers = new LinkedHashMap<>(); + for (Map.Entry> entry : connection.getHeaderFields().entrySet()) { + if (entry.getKey() != null) { + headers.put(entry.getKey(), entry.getValue()); + } + } + return headers; + } + + /** Content-Type 의 charset 파라미터를 파싱. 없거나 인식 불가면 UTF-8. */ + private static String charsetOf(String contentType) { + if (contentType != null) { + for (String part : contentType.split(";")) { + String token = part.trim(); + if (token.toLowerCase().startsWith("charset=")) { + String charset = token.substring("charset=".length()).replace("\"", "").trim(); + if (!charset.isEmpty() && java.nio.charset.Charset.isSupported(charset)) { + return charset; + } + } + } + } + return DEFAULT_CHARSET; } private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException { 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 c7dfd23..d0d0d8b 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 @@ -11,9 +11,13 @@ import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; +import java.util.Arrays; import java.util.Enumeration; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import javax.servlet.Filter; import javax.servlet.FilterChain; @@ -32,6 +36,13 @@ public class ApiTesterFilter implements Filter { private static final Logger logger = LoggerFactory.getLogger(ApiTesterFilter.class); + /** 대상 응답에서 클라이언트로 되돌리지 않는 헤더 (소문자 비교). */ + private static final Set BLOCKED_RESPONSE_HEADERS = new HashSet<>(Arrays.asList( + "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", + "te", "trailer", "transfer-encoding", "upgrade", + "content-length", "content-encoding", "content-type", + "set-cookie", "set-cookie2")); + @Override public void init(FilterConfig filterConfig) throws ServletException { logger.debug("ApiTesterFilter initialized"); @@ -141,14 +152,16 @@ public class ApiTesterFilter implements Filter { logger.debug("TOKEN_GW forward - auditId={}, target={}, bodyLen={}, body={}", auditId, target, body.length(), StringMaskingUtil.maskFormBody(body)); } - String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body); + APISender.ApiResponse tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body); - response.setContentType("application/json"); - response.getWriter().println(tokenResponse); + writeUpstream(response, tokenResponse); } } else { - ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod()); + // 스펙 조회는 original-api-id 헤더(Swagger UI 가 x-original-api-id 확장에서 전달) 우선. + // mock/gw 응답유형은 서버주소가 mockUrl·GW base 로 치환돼 original-url 의 path 가 저장된 + // api_url 과 일치하지 않으므로 URL 매칭만으로는 스펙을 찾지 못한다. + ApiSpecInfoDto apiSpecInfoDto = selectSpec(apiSpecInfoDtoService, httpServletRequest, url); // URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지) if (apiSpecInfoDto == null) { @@ -212,19 +225,18 @@ public class ApiTesterFilter implements Filter { requestBody == null ? 0 : requestBody.length(), maskHeaders(headers)); } APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); - String responseStr; + APISender.ApiResponse upstream; if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) { - responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody); + upstream = apiSender.requestPost(targetUri, headers, paramMap, requestBody); } else { - responseStr = apiSender.requestGet(targetUri, headers, paramMap); + upstream = apiSender.requestGet(targetUri, headers, paramMap); } if (logger.isDebugEnabled()) { - logger.debug("{} response - auditId={}, target={}, respLen={}, preview={}", - auditType, auditId, targetUri, - responseStr == null ? 0 : responseStr.length(), previewOf(responseStr)); + logger.debug("{} response - auditId={}, target={}, status={}, respLen={}, preview={}", + auditType, auditId, targetUri, upstream.getStatus(), + upstream.getBody() == null ? 0 : upstream.getBody().length(), previewOf(upstream.getBody())); } - response.setContentType("application/json"); - response.getWriter().println(responseStr); + writeUpstream(response, upstream); } } catch (java.net.SocketTimeoutException e) { @@ -305,6 +317,25 @@ public class ApiTesterFilter implements Filter { return sb.toString(); } + /** + * 호출 대상 API 명세를 찾는다. {@code original-api-id} 헤더가 있으면 API ID 로, 없으면 기존처럼 + * {@code original-url} 의 path + 메서드로 조회한다. 없으면 null. + * + *

API ID 는 클라이언트가 보내는 값이므로 URL 조회와 동일하게 포탈 게시(display_yn='Y') 인 + * 스펙만 허용한다 — 비공개 API 가 ID 추측으로 호출되지 않도록.

+ */ + private ApiSpecInfoDto selectSpec(ApiService apiService, HttpServletRequest request, String url) { + String apiId = request.getHeader("original-api-id"); + if (apiId != null && !apiId.trim().isEmpty()) { + ApiSpecInfoDto dto = apiService.selectDetail(apiId.trim()); + if (dto != null && "Y".equalsIgnoreCase(dto.getDisplayYn())) { + return dto; + } + logger.debug("original-api-id 로 게시된 스펙을 찾지 못해 URL 매칭으로 폴백 - apiId={}", apiId); + } + return apiService.selectDetailByURLAndMethod(parseUri(url), request.getMethod()); + } + /** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */ private Map extractQueryParams(String originalUrl) { Map paramMap = new HashMap<>(); @@ -320,7 +351,53 @@ public class ApiTesterFilter implements Filter { return paramMap; } - /** 상태코드 + JSON 본문 응답. */ + /** + * 프록시 대상(GW/mock) 응답을 상태코드·헤더·본문 그대로 클라이언트에 전달한다. + * + *

대상이 404/400 을 내려도 200 으로 포장되지 않도록 상태코드를 그대로 세팅한다. + * 단 아래 헤더는 전달하지 않는다.

+ *
    + *
  • hop-by-hop 헤더(connection/keep-alive/transfer-encoding 등) — 연결 단위 헤더라 재전송 대상 아님
  • + *
  • content-length / content-encoding — 본문을 문자열로 다시 쓰므로 원본 길이·압축 정보가 맞지 않음
  • + *
  • set-cookie — 대상 쿠키가 포탈 도메인에 심겨 세션 쿠키를 덮어쓸 수 있어 차단
  • + *
+ */ + private void writeUpstream(ServletResponse response, APISender.ApiResponse upstream) throws IOException { + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setStatus(upstream.getStatus()); + + for (Map.Entry> entry : upstream.getHeaders().entrySet()) { + String name = entry.getKey(); + if (isBlockedResponseHeader(name) || entry.getValue() == null) { + continue; + } + boolean first = true; + for (String value : entry.getValue()) { + if (value == null) { + continue; + } + if (first) { + httpResponse.setHeader(name, value); + first = false; + } else { + httpResponse.addHeader(name, value); + } + } + } + + // Content-Type 은 응답 문자셋까지 결정하므로 헤더 복사와 별개로 마지막에 확정한다. + String contentType = upstream.getContentType(); + response.setContentType(contentType == null || contentType.trim().isEmpty() + ? "application/json" : contentType); + + response.getWriter().println(upstream.getBody() == null ? "" : upstream.getBody()); + } + + /** 클라이언트로 되돌리면 안 되는 응답 헤더인지. */ + private boolean isBlockedResponseHeader(String name) { + return name == null || BLOCKED_RESPONSE_HEADERS.contains(name.toLowerCase()); + } + /** forward 헤더 debug 출력용 — 민감 헤더(토큰/쿠키 등)는 StringMaskingUtil 로 마스킹. */ private String maskHeaders(Map headers) { StringBuilder sb = new StringBuilder("{"); diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/mapper/ApiSpecMapper.java b/src/main/java/com/eactive/apim/portal/apps/apis/mapper/ApiSpecMapper.java index 4787100..bd6ddc6 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apis/mapper/ApiSpecMapper.java +++ b/src/main/java/com/eactive/apim/portal/apps/apis/mapper/ApiSpecMapper.java @@ -11,8 +11,14 @@ public interface ApiSpecMapper { ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo); + /** + * 조회 결과가 없으면 {@code null} 을 반환한다. + * + *

빈 DTO 를 반환하면 호출측의 "스펙 없음" 분기가 동작하지 않고 모든 필드가 null 인 DTO 로 + * 진행돼(예: {@code responseType == null} → sample 취급) 오동작한다.

+ */ default ApiSpecInfoDto map(Optional optionalApiSpecInfo) { - return optionalApiSpecInfo.map(this::mapToDto).orElse(new ApiSpecInfoDto()); + return optionalApiSpecInfo.map(this::mapToDto).orElse(null); } } 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 f515c98..8de3455 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 @@ -85,8 +85,12 @@ public class DjbTestbedSpecController { DjbAuthType authType = authService.resolveAuthType(id); String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType); - return alwaysGateway - ? serverRewriter.rewriteServerToGateway(enriched, request) - : serverRewriter.rewriteServer(enriched, spec.get(), request); + if (alwaysGateway) { + return serverRewriter.rewriteServerToGateway(enriched, request); + } + // UI 용 spec 에만 API ID 를 심는다. mock/gw 는 서버주소가 치환돼 URL path 로는 스펙을 되찾을 수 없으므로 + // Swagger UI 가 original-api-id 헤더로 프록시에 API ID 를 전달하게 한다. + return enricher.injectOriginalApiId( + serverRewriter.rewriteServer(enriched, spec.get(), request), id); } } 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 12641f6..8535186 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 @@ -35,6 +35,13 @@ public class DjbSwaggerSpecEnricher { public static final String OAUTH_SCHEME = "djbOAuth"; public static final String API_KEY_SCHEME = "djbApiKey"; + /** operation 에 주입하는 API ID 확장 키. 템플릿 JS 가 {@code original-api-id} 헤더로 프록시에 전달한다. */ + public static final String API_ID_EXTENSION = "x-original-api-id"; + + /** OpenAPI path item 에서 operation 으로 취급하는 필드 (그 외 parameters/servers/summary 등은 제외). */ + private static final String[] HTTP_METHODS = + {"get", "put", "post", "delete", "options", "head", "patch", "trace"}; + public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException { JsonNode parsed = objectMapper.readTree(specJson); if (!parsed.isObject()) { @@ -71,6 +78,42 @@ public class DjbSwaggerSpecEnricher { return objectMapper.writeValueAsString(root); } + /** + * spec 의 모든 operation 에 {@value #API_ID_EXTENSION} 를 주입한다. + * + *

테스트베드 프록시({@code /api/call-api})는 스펙을 찾을 때 {@code original-url} 의 path 로 매칭했는데, + * mock/gw 응답유형에서는 서버 주소가 mockUrl·GW base 로 치환되어 path 가 저장된 {@code api_url} 과 + * 일치하지 않는다. 템플릿 JS 가 이 확장값을 {@code original-api-id} 헤더로 보내면 프록시가 API ID 로 + * 정확히 스펙을 찾을 수 있다.

+ * + *

파싱 불가·paths 부재 시 원본 유지(멱등).

+ */ + public String injectOriginalApiId(String specJson, String apiId) throws JsonProcessingException { + if (specJson == null || apiId == null || apiId.trim().isEmpty()) { + return specJson; + } + JsonNode parsed = objectMapper.readTree(specJson); + if (!parsed.isObject()) { + return specJson; + } + JsonNode paths = parsed.get("paths"); + if (paths == null || !paths.isObject()) { + return specJson; + } + for (JsonNode pathItem : paths) { + if (!pathItem.isObject()) { + continue; + } + for (String method : HTTP_METHODS) { + JsonNode operation = pathItem.get(method); + if (operation != null && operation.isObject()) { + ((ObjectNode) operation).put(API_ID_EXTENSION, apiId); + } + } + } + return objectMapper.writeValueAsString(parsed); + } + /** {@code { "type":"apiKey", "in":"header", "name":"
" }} — 2.0/3.0 동일 형태. */ private ObjectNode buildApiKeyScheme(DjbAuthType authType) { String headerName = (authType == DjbAuthType.OAUTH)