DEFAULT spec 서버 치환 제거:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- GW/sentinel 치환 로직 삭제 및 serverRewriter 호출 제거
- mock path 분리/servers 덮어쓰기 로직 및 공통 유틸 추가
This commit is contained in:
Rinjae
2026-08-11 16:56:17 +09:00
parent 2a9980ab16
commit d6186de009
2 changed files with 120 additions and 39 deletions
@@ -71,11 +71,11 @@ public class DjbTestbedSpecController {
*/
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
// 클래스패스 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로
// (PORTAL_MOCK_TOKEN_PATH)라 GW 호스트를 붙이면 실재하지 않는 주소가 되고, servers 를
// 비워 두면 Swagger UI 가 문서 origin(포탈)을 사용해 프록시가 mock 토큰을 발급한다.
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return alwaysGateway
? serverRewriter.rewriteServerToGateway(content, request)
: serverRewriter.rewriteServer(content, null, request);
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
}
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
@@ -2,7 +2,12 @@ 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.net.URI;
import java.util.Iterator;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -12,58 +17,62 @@ import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
/**
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
* testbed spec(JSON)의 서버 주소를 제공 시점에 실주소로 치환한다.
*
* <ul>
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
* <li>mock : {@code ApiSpecInfo.mockUrl} 을 origin/path 로 분해 — origin 은 서버, path 는 paths 키로
* 교체해 어댑터/오퍼레이션 경로가 뒤에 덧붙지 않게 한다</li>
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
* </ul>
*
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
* admin(app.js buildOpenApiSpec)은 저장 서버를 {@value #SERVER_SENTINEL} 로만 남기고 실주소를 굳히지
* 않는다 — 저장본을 환경 독립으로 유지하기 위함이다. 실주소 결정은 이 서비스가 전담한다.
*
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
* <p>치환은 <b>서버 값 유무와 무관하게</b> 트리 조작으로 덮어쓴다. 과거 admin 이 실주소를 굳혀 저장한
* spec(sentinel 없음)도 현재 환경 기준으로 정정되며, 저장 환경(dev)의 GW 주소가 따라오지 않는다.
* 다만 그 시절 mock 저장본은 {@code paths} 키가 이미 mock 경로로 바뀌어 있어 다운로드용
* ({@link #rewriteServerToGateway}) 경로는 재저장 전까지 어댑터경로로 복원되지 않는다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecServerRewriter {
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
/** admin(app.js buildOpenApiSpec)이 저장본 서버에 남기는 고정 sentinel. */
public static final String SERVER_SENTINEL = "http://swagger-server-url";
private final DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper;
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
/** 응답유형(sample/mock/gw)별 실주소로 서버를 치환한 spec JSON 반환. */
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
String responseType = (spec == null || !StringUtils.hasText(spec.getResponseType()))
? "sample" : spec.getResponseType().trim();
if ("gw".equalsIgnoreCase(responseType)) {
return applyServer(specJson, gatewayProperty.resolveApiBaseUrl(originOf(request)), null);
}
String base = stripTrailingSlash(resolveBase(spec, request));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
if ("mock".equalsIgnoreCase(responseType)) {
String mockUrl = (spec == null) ? null : spec.getMockUrl();
if (!StringUtils.hasText(mockUrl)) {
// mock 인데 주소 미입력 — 포탈 origin 으로 폴백(기존 동작)
return applyServer(specJson, originOf(request), null);
}
String[] parts = splitFullUrl(mockUrl.trim());
return applyServer(specJson, parts[0], parts[1]);
}
return specJson.replace(SERVER_SENTINEL, base);
// sample(기본): 포탈 origin
return applyServer(specJson, originOf(request), null);
}
/**
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
* responseType 을 무시하고 항상 GW 주소로 치환한 spec JSON 반환 — 외부 공개/다운로드용
* (swagger.json/yaml) 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 응답유형 분기 사용.)
*/
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
return applyServer(specJson, gatewayProperty.resolveApiBaseUrl(originOf(request)), null);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
@@ -80,18 +89,90 @@ public class DjbTestbedSpecServerRewriter {
}
}
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));
/**
* {@code servers} 를 지정 주소로 덮어쓰고, {@code newPathKey} 가 있으면 단일 path 키를 교체한다.
*
* @param serverUrl 서버 주소. 비어 있으면 {@code servers} 를 제거해 Swagger UI 가 문서 origin 을 쓰게 한다.
* @param newPathKey mock 처럼 경로까지 치환해야 할 때의 새 path 키. null 이면 경로 유지.
* @return 치환된 JSON. 파싱 실패·형태 불일치 시 원본 유지(멱등).
*/
private String applyServer(String specJson, String serverUrl, String newPathKey) {
if (specJson == null) {
return null;
}
if ("mock".equalsIgnoreCase(rt)) {
String mock = (spec == null) ? null : spec.getMockUrl();
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
try {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
String base = stripTrailingSlash(serverUrl);
if (StringUtils.hasText(base)) {
ArrayNode servers = objectMapper.createArrayNode();
servers.add(objectMapper.createObjectNode().put("url", base));
root.set("servers", servers);
} else {
root.remove("servers");
}
if (newPathKey != null) {
renameSinglePath(root, newPathKey);
}
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
log.error("spec 서버 치환 실패, 원본 유지 - serverUrl={}, newPathKey={}", serverUrl, newPathKey, e);
return specJson;
}
}
/**
* {@code paths} 의 유일한 키를 {@code newPathKey} 로 교체한다.
*
* <p>testbed spec 은 오퍼레이션 1건(= path 1건) 기준으로 생성되므로 단일 키만 다룬다.
* 키가 없거나 2개 이상이면 어느 것을 mock 경로에 대응시킬지 알 수 없어 원본을 유지한다.</p>
*/
private void renameSinglePath(ObjectNode root, String newPathKey) {
JsonNode paths = root.get("paths");
if (paths == null || !paths.isObject()) {
return;
}
ObjectNode pathsNode = (ObjectNode) paths;
if (pathsNode.size() != 1) {
log.warn("paths 가 단일 키가 아니어서 mock 경로 치환 생략 - size={}", pathsNode.size());
return;
}
Iterator<String> names = pathsNode.fieldNames();
String oldKey = names.next();
if (oldKey.equals(newPathKey)) {
return;
}
JsonNode pathItem = pathsNode.get(oldKey);
pathsNode.remove(oldKey);
pathsNode.set(newPathKey, pathItem);
}
/**
* 전체 URL → {@code [origin, path]}. mock 주소를 서버/경로로 분해해 경로가 중복 부착되지 않게 한다.
* 파싱 실패(스킴 누락 등)면 통째로 origin 취급하고 경로는 {@code "/"}.
*/
private static String[] splitFullUrl(String url) {
try {
URI uri = new URI(url);
if (uri.getScheme() == null || uri.getHost() == null) {
return new String[]{stripTrailingSlash(url), "/"};
}
String origin = uri.getScheme() + "://" + uri.getHost()
+ (uri.getPort() < 0 ? "" : ":" + uri.getPort());
String path = StringUtils.hasText(uri.getRawPath()) ? uri.getRawPath() : "/";
if (StringUtils.hasText(uri.getRawQuery())) {
path = path + "?" + uri.getRawQuery();
}
return new String[]{origin, path};
} catch (Exception e) {
log.warn("mock URL 파싱 실패, 통째로 서버 취급 - url={}, cause={}", url, e.getMessage());
return new String[]{stripTrailingSlash(url), "/"};
}
// sample(기본): 포탈 origin
return originOf(request);
}
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */