- Swagger spec 서버 주소 GW 기준 치환 통합 - UI/다운로드 동일 기준
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled

- ApiTesterFilter: gw 호출 시 base-url 재조립 처리 추가
- admin 스펙 저장 시 실주소 고정 제거 - 재치환 유연성 확보
This commit is contained in:
Rinjae
2026-08-11 19:00:18 +09:00
parent d6186de009
commit 8356fc3029
5 changed files with 132 additions and 153 deletions
@@ -44,13 +44,14 @@ public class TestbedSpecController {
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
/** default 토큰 spec 또는 저장 spec(서버 → GW 주소 치환)을 JSON 으로 반환. 없으면 null. */
private String buildSpecJson(String id, HttpServletRequest request) {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
try {
// 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로라
// GW 호스트를 붙이면 실재하지 않는 주소가 된다(servers 없음 → 문서 origin 사용).
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request);
return new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
} catch (IOException e) {
log.error("Failed to read default token api spec file", e);
return null;
@@ -61,6 +62,6 @@ public class TestbedSpecController {
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
return null;
}
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
return serverRewriter.rewriteServerToGateway(spec.get().getTestbedSpec(), request);
}
}
@@ -203,11 +203,12 @@ public class ApiTesterFilter implements Filter {
String targetUri;
Map<String, String[]> paramMap;
if (gw) {
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
// original-url 은 브라우저가 spec 서버주소(대외)로 만든 값이라 포탈 서버에서 도달하지 못할
// 수 있다. path/query 만 떼어 대내 base-url 에 재조립한 주소로 forward 한다.
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
headers.remove("host");
headers.remove("Host");
targetUri = url;
targetUri = gatewayProperty.resolveGatewayCallUrl(url);
paramMap = new HashMap<>();
} else {
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
@@ -2,7 +2,10 @@ package com.eactive.apim.portal.djb.testbed.config;
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.net.URI;
import java.net.URISyntaxException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@@ -19,6 +22,7 @@ import org.springframework.util.StringUtils;
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedGatewayProperty {
private final PortalPropertyService portalPropertyService;
@@ -33,8 +37,17 @@ public class DjbTestbedGatewayProperty {
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 KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
public static final String KEY_SPEC_SERVER_URL = "djb.gateway.spec-server-url";
public static final String DEFAULT_BASE_URL = "PortalMock";
/**
* {@link #KEY_SPEC_SERVER_URL} 미설정 표식. spec 서버 주소를 {@link #resolveApiBaseUrl} 기존 규칙
* (GATEWAY→base-url, PortalMock→포탈 origin)으로 결정한다는 뜻.
*
* <p>빈 문자열을 기본값으로 쓰면 {@code PTL_PROPERTY.property_value} 가 NOT NULL 이라
* Oracle 에서 빈 문자열=NULL 로 저장되며 제약 위반이 나므로, 비어 있지 않은 표식을 쓴다.</p>
*/
public static final String DEFAULT_SPEC_SERVER_URL = "auto";
public static final String DEFAULT_TIMEOUT_SEC = "10";
public static final String DEFAULT_USE_PROXY = "true";
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
@@ -130,15 +143,42 @@ public class DjbTestbedGatewayProperty {
}
/**
* OAuth 토큰 발급 URL.
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
* GATEWAY → {@code baseUrl + tokenPath}.
* 브라우저에 내려주는 OAuth 토큰 발급 URL — spec 노출 주소({@link #resolveSpecServerUrl}) 기준.
*
* <p>PortalMock → {@code portalOrigin + /api/v1/oauth/token}(기존 mock 필터가 가로챔),
* GATEWAY → {@code specServerUrl + tokenPath}.</p>
*
* <p>대외/대내 주소가 다른 환경에서 <b>직접호출 모드</b>({@code djb.gateway.token-use-proxy=false})는
* 브라우저가 이 주소로 바로 나가므로 대외 주소여야 한다. 프록시 경유일 때는 이 값이
* {@code original-url} 헤더로만 전달되고, 실제 forward 는 필터가
* {@code baseUrl + tokenPath}(대내)로 수행한다.</p>
*/
public String resolveTokenUrl(String portalOrigin) {
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
}
return stripTrailingSlash(baseUrl()) + tokenPath();
return stripTrailingSlash(resolveSpecServerUrl(portalOrigin)) + tokenPath();
}
/**
* gw 응답유형의 실제 forward 대상 URL. 브라우저가 spec 서버주소(대외)로 만든 {@code originalUrl} 에서
* path/query 만 떼어 <b>대내 {@code base-url}</b> 에 재조립한다 — 포탈 서버는 대외 주소로 나갈 수 없다.
*
* <p>PortalMock 모드는 실 GW 가 없으므로 원본을 그대로 쓴다. URL 파싱 실패 시에도 원본 유지(멱등).</p>
*/
public String resolveGatewayCallUrl(String originalUrl) {
if (originalUrl == null || resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
return originalUrl;
}
try {
URI uri = new URI(originalUrl);
String path = StringUtils.hasText(uri.getRawPath()) ? uri.getRawPath() : "";
String query = StringUtils.hasText(uri.getRawQuery()) ? "?" + uri.getRawQuery() : "";
return stripTrailingSlash(baseUrl()) + path + query;
} catch (URISyntaxException e) {
log.warn("gw 호출 URL 파싱 실패, original-url 그대로 사용 - url={}, cause={}", originalUrl, e.getMessage());
return originalUrl;
}
}
/**
@@ -153,6 +193,30 @@ public class DjbTestbedGatewayProperty {
return stripTrailingSlash(baseUrl());
}
/**
* spec({@code servers[0].url})에 노출할 주소. 응답유형과 무관하게 이 값 하나로 고정된다.
*
* <p>{@link #KEY_SPEC_SERVER_URL} 에 주소를 넣으면 그 값을 그대로 쓴다. PortalMock 모드처럼
* {@code base-url} 이 실제 URL 이 아니어서 노출할 GW 주소를 코드가 알 수 없을 때 이 값으로 고정한다.
* 대외 공개 도메인이 내부 GW 주소와 다른 경우에도 사용한다.</p>
*
* <p>{@value #DEFAULT_SPEC_SERVER_URL}(기본) 이면 {@link #resolveApiBaseUrl} 규칙을 그대로 따른다.</p>
*/
public String resolveSpecServerUrl(String portalOrigin) {
String configured = specServerUrl();
if (StringUtils.hasText(configured) && !DEFAULT_SPEC_SERVER_URL.equalsIgnoreCase(configured.trim())) {
return stripTrailingSlash(configured);
}
return resolveApiBaseUrl(portalOrigin);
}
/** spec servers 노출 주소. {@value #DEFAULT_SPEC_SERVER_URL} 이면 미설정(자동 결정). */
public String specServerUrl() {
return resolve(KEY_SPEC_SERVER_URL, DEFAULT_SPEC_SERVER_URL,
"spec(servers)에 노출할 API 호출 주소. \"" + DEFAULT_SPEC_SERVER_URL
+ "\" 이면 GW Base URL(PortalMock 이면 포탈 주소)로 자동 결정");
}
private String resolve(String key, String defaultValue, String description) {
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
return StringUtils.hasText(value) ? value.trim() : defaultValue;
@@ -48,28 +48,31 @@ public class DjbTestbedSpecController {
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, true);
String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, true);
String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
}
/** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
/**
* Swagger UI 전용 spec. 서버 주소는 다운로드용과 동일하게 GW 기준이며, 여기에만
* {@code x-original-api-id} 를 심어 UI 실행 시 프록시가 API ID 로 스펙을 찾게 한다.
*/
@GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, false);
String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
/**
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 GW 치환)을 JSON 으로 반환. 없으면 null.
* @param forUi true 면 Swagger UI 실행용으로 {@code x-original-api-id} 를 주입한다
*/
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
private String buildSpecJson(String id, HttpServletRequest request, boolean forUi) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) {
// 클래스패스 기본 토큰 spec 은 서버 치환 대상이 아니다. path 가 포탈 mock 토큰 경로
// (PORTAL_MOCK_TOKEN_PATH)라 GW 호스트를 붙이면 실재하지 않는 주소가 되고, servers 를
@@ -85,12 +88,12 @@ public class DjbTestbedSpecController {
DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
if (alwaysGateway) {
return serverRewriter.rewriteServerToGateway(enriched, request);
String rewritten = serverRewriter.rewriteServerToGateway(enriched, request);
if (!forUi) {
return rewritten;
}
// 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);
// UI 용 spec 에만 API ID 를 심는다. 서버주소가 GW 로 고정돼 mock 은 호출 URL path 로 스펙을
// 되찾을 수 없으므로, Swagger UI 가 original-api-id 헤더로 프록시에 API ID 를 전달하게 한다.
return enricher.injectOriginalApiId(rewritten, id);
}
}
@@ -1,13 +1,10 @@
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;
@@ -17,62 +14,61 @@ import org.yaml.snakeyaml.DumperOptions;
import org.yaml.snakeyaml.Yaml;
/**
* testbed spec(JSON)의 서버 주소를 제공 시점에 주소로 치환한다.
* testbed spec(JSON)의 서버 주소를 제공 시점에 게이트웨이 주소로 치환한다.
*
* <ul>
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
* <li>mock : {@code ApiSpecInfo.mockUrl} 을 origin/path 로 분해 — origin 은 서버, path 는 paths 키로
* 교체해 어댑터/오퍼레이션 경로가 뒤에 덧붙지 않게 한다</li>
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
* </ul>
* <p><b>spec 의 {@code servers} 는 응답유형(sample/mock/gw)과 무관하게 항상 GW 기준이다.</b>
* spec 은 "이 API 를 실제로 어디로 호출하는가"의 공개 계약이고 그 기준은 게이트웨이 하나다.
* admin(app.js buildOpenApiSpec)도 같은 규칙으로 저장하며, 포탈은 제공 시점에 자기 환경의
* {@link DjbTestbedGatewayProperty#resolveSpecServerUrl} 로 덮어써 저장 환경(dev)의 주소가 다른 환경까지
* 따라오지 않게 한다. 노출 주소를 명시 고정해야 하면
* {@code djb.gateway.spec-server-url}(PTL_PROPERTY) 에 넣는다.
*
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
* admin(app.js buildOpenApiSpec)은 저장 시 서버를 {@value #SERVER_SENTINEL} 로만 남기고 실주소를 굳히지
* 않는다 — 저장본을 환경 독립으로 유지하기 위함이다. 실주소 결정은 이 서비스가 전담한다.
* <p>응답유형에 따른 차이는 <b>테스트베드 동작</b>에서만 난다 — Swagger UI 의 실행은
* {@code /api/call-api} 프록시를 타고, {@code ApiTesterFilter} 가 응답유형별로
* sample(저장 샘플 응답) / mock({@code mockUrl} forward) / gw(게이트웨이 forward) 를 가른다.
* 즉 표시 주소는 GW 로 고정이고 호출 대상만 갈린다.
*
* <p>치환은 <b>서버 값 유무와 무관하게</b> 트리 조작으로 덮어쓴다. 과거 admin 이 실주소를 굳혀 저장한
* spec(sentinel 없음)도 현재 환경 기준으로 정정되며, 저장 환경(dev)의 GW 주소가 따라오지 않는다.
* 다만 그 시절 mock 저장본은 {@code paths} 키가 이미 mock 경로로 바뀌어 있어 다운로드용
* ({@link #rewriteServerToGateway}) 경로는 재저장 전까지 어댑터경로로 복원되지 않는다.
* <p>치환은 서버 값 유무와 무관하게 트리 조작으로 덮어쓴다(문자열 치환 아님). 과거 admin 이 mock/포탈
* 주소를 굳혀 저장한 spec 도 GW 기준으로 정정된다. 다만 그 시절 mock 저장본은 {@code paths} 키가 mock
* 경로로 바뀌어 있어, 어댑터경로 복원은 admin 재저장이 필요하다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class DjbTestbedSpecServerRewriter {
/** admin(app.js buildOpenApiSpec)이 저장본 서버에 남기는 고정 sentinel. */
public static final String SERVER_SENTINEL = "http://swagger-server-url";
private final DjbTestbedGatewayProperty gatewayProperty;
private final ObjectMapper objectMapper;
/** 응답유형(sample/mock/gw)별 실주소로 서버를 치환한 spec JSON 반환. */
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
String responseType = (spec == null || !StringUtils.hasText(spec.getResponseType()))
? "sample" : spec.getResponseType().trim();
if ("gw".equalsIgnoreCase(responseType)) {
return applyServer(specJson, gatewayProperty.resolveApiBaseUrl(originOf(request)), null);
}
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]);
}
// sample(기본): 포탈 origin
return applyServer(specJson, originOf(request), null);
}
/**
* responseType 을 무시하고 항상 GW 주소로 치환한 spec JSON 반환 — 외부 공개/다운로드용
* (swagger.json/yaml) 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 응답유형 분기 사용.)
* spec 의 {@code servers} 를 GW 주소로 치환해 반환한다. UI 표시용·다운로드용 구분 없이 동일 기준.
*
* @return 치환된 JSON. 파싱 실패·형태 불일치 시 원본 유지(멱등).
*/
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
return applyServer(specJson, gatewayProperty.resolveApiBaseUrl(originOf(request)), null);
if (specJson == null) {
return null;
}
String base = stripTrailingSlash(gatewayProperty.resolveSpecServerUrl(originOf(request)));
try {
JsonNode parsed = objectMapper.readTree(specJson);
if (!parsed.isObject()) {
return specJson;
}
ObjectNode root = (ObjectNode) parsed;
if (StringUtils.hasText(base)) {
ArrayNode servers = objectMapper.createArrayNode();
servers.add(objectMapper.createObjectNode().put("url", base));
root.set("servers", servers);
} else {
// GW 주소 미확정 — servers 를 비워 Swagger UI 가 문서 origin(포탈)을 쓰게 한다.
root.remove("servers");
}
return objectMapper.writeValueAsString(root);
} catch (Exception e) {
log.error("spec 서버 치환 실패, 원본 유지 - base={}", base, e);
return specJson;
}
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
@@ -89,92 +85,6 @@ public class DjbTestbedSpecServerRewriter {
}
}
/**
* {@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;
}
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), "/"};
}
}
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
public static String originOf(HttpServletRequest request) {
if (request == null) {