- API Tester 프록시 응답 헤더 전달 로직 개선 - BLOCKED 헤더 추가
- API ID 기반 명세 조회 기능 추가 - GW/Mock path 매칭 문제 해결 - APISender에 ApiResponse 클래스 도입 - 응답 상태코드/본문/헤더 반환
This commit is contained in:
@@ -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) 응답을 상태코드까지 포함해 전달하기 위한 홀더.
|
||||
*
|
||||
* <p>본문만 반환하면 대상이 4xx/5xx 를 내려도 호출측이 200 으로 되돌려주게 되므로
|
||||
* 상태코드와 Content-Type 을 함께 담는다.</p>
|
||||
*/
|
||||
public static class ApiResponse {
|
||||
|
||||
private final int status;
|
||||
private final String body;
|
||||
private final String contentType;
|
||||
private final Map<String, List<String>> headers;
|
||||
|
||||
public ApiResponse(int status, String body, String contentType, Map<String, List<String>> headers) {
|
||||
this.status = status;
|
||||
this.body = body;
|
||||
this.contentType = contentType;
|
||||
this.headers = headers == null
|
||||
? Collections.<String, List<String>>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<String, List<String>> 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<String, String> headers, Map<String, String[]> params) throws IOException {
|
||||
public ApiResponse requestGet(String uri, Map<String, String> headers, Map<String, String[]> 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<String, String> headers, Map<String, String[]> params, String requestBody) throws IOException {
|
||||
public ApiResponse requestPost(String uri, Map<String, String> headers, Map<String, String[]> 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·본문으로 읽는다.
|
||||
*
|
||||
* <p>4xx/5xx 는 {@code getInputStream()} 이 IOException 을 던지므로 errorStream 으로 본문을 읽고,
|
||||
* 본문이 아예 없는 응답(errorStream == null)은 빈 문자열로 처리한다.</p>
|
||||
*/
|
||||
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<String, List<String>> copyHeaders(HttpURLConnection connection) {
|
||||
Map<String, List<String>> headers = new LinkedHashMap<>();
|
||||
for (Map.Entry<String, List<String>> 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 {
|
||||
|
||||
@@ -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<String> 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.
|
||||
*
|
||||
* <p>API ID 는 클라이언트가 보내는 값이므로 URL 조회와 동일하게 <b>포탈 게시(display_yn='Y')</b> 인
|
||||
* 스펙만 허용한다 — 비공개 API 가 ID 추측으로 호출되지 않도록.</p>
|
||||
*/
|
||||
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<String, String[]> extractQueryParams(String originalUrl) {
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
@@ -320,7 +351,53 @@ public class ApiTesterFilter implements Filter {
|
||||
return paramMap;
|
||||
}
|
||||
|
||||
/** 상태코드 + JSON 본문 응답. */
|
||||
/**
|
||||
* 프록시 대상(GW/mock) 응답을 상태코드·헤더·본문 그대로 클라이언트에 전달한다.
|
||||
*
|
||||
* <p>대상이 404/400 을 내려도 200 으로 포장되지 않도록 상태코드를 그대로 세팅한다.
|
||||
* 단 아래 헤더는 전달하지 않는다.</p>
|
||||
* <ul>
|
||||
* <li>hop-by-hop 헤더(connection/keep-alive/transfer-encoding 등) — 연결 단위 헤더라 재전송 대상 아님</li>
|
||||
* <li>content-length / content-encoding — 본문을 문자열로 다시 쓰므로 원본 길이·압축 정보가 맞지 않음</li>
|
||||
* <li>set-cookie — 대상 쿠키가 포탈 도메인에 심겨 세션 쿠키를 덮어쓸 수 있어 차단</li>
|
||||
* </ul>
|
||||
*/
|
||||
private void writeUpstream(ServletResponse response, APISender.ApiResponse upstream) throws IOException {
|
||||
HttpServletResponse httpResponse = (HttpServletResponse) response;
|
||||
httpResponse.setStatus(upstream.getStatus());
|
||||
|
||||
for (Map.Entry<String, List<String>> 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<String, String> headers) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
|
||||
@@ -11,8 +11,14 @@ public interface ApiSpecMapper {
|
||||
|
||||
ApiSpecInfoDto mapToDto(ApiSpecInfo apiSpecInfo);
|
||||
|
||||
/**
|
||||
* 조회 결과가 없으면 {@code null} 을 반환한다.
|
||||
*
|
||||
* <p>빈 DTO 를 반환하면 호출측의 "스펙 없음" 분기가 동작하지 않고 모든 필드가 null 인 DTO 로
|
||||
* 진행돼(예: {@code responseType == null} → sample 취급) 오동작한다.</p>
|
||||
*/
|
||||
default ApiSpecInfoDto map(Optional<ApiSpecInfo> optionalApiSpecInfo) {
|
||||
return optionalApiSpecInfo.map(this::mapToDto).orElse(new ApiSpecInfoDto());
|
||||
return optionalApiSpecInfo.map(this::mapToDto).orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+7
-3
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 를 주입한다.
|
||||
*
|
||||
* <p>테스트베드 프록시({@code /api/call-api})는 스펙을 찾을 때 {@code original-url} 의 path 로 매칭했는데,
|
||||
* mock/gw 응답유형에서는 서버 주소가 mockUrl·GW base 로 치환되어 path 가 저장된 {@code api_url} 과
|
||||
* 일치하지 않는다. 템플릿 JS 가 이 확장값을 {@code original-api-id} 헤더로 보내면 프록시가 API ID 로
|
||||
* 정확히 스펙을 찾을 수 있다.</p>
|
||||
*
|
||||
* <p>파싱 불가·paths 부재 시 원본 유지(멱등).</p>
|
||||
*/
|
||||
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":"<header>" }} — 2.0/3.0 동일 형태. */
|
||||
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
|
||||
String headerName = (authType == DjbAuthType.OAUTH)
|
||||
|
||||
Reference in New Issue
Block a user