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)