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 8de3455..07ad9b5 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 @@ -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 spec = apiSpecInfoService.findById(id); diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java index aaaf7b7..7d5017d 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/service/DjbTestbedSpecServerRewriter.java @@ -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)의 서버 주소를 제공 시점에 실주소로 치환한다. * * * *

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} 로만 남기고 실주소를 굳히지 + * 않는다 — 저장본을 환경 독립으로 유지하기 위함이다. 실주소 결정은 이 서비스가 전담한다. * - *

sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등). + *

치환은 서버 값 유무와 무관하게 트리 조작으로 덮어쓴다. 과거 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} 로 교체한다. + * + *

testbed spec 은 오퍼레이션 1건(= path 1건) 기준으로 생성되므로 단일 키만 다룬다. + * 키가 없거나 2개 이상이면 어느 것을 mock 경로에 대응시킬지 알 수 없어 원본을 유지한다.

+ */ + 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 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-* 우선. */