From 8356fc30297ac6539e4444f738ba1ed9d2d1b366 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Tue, 11 Aug 2026 19:00:18 +0900 Subject: [PATCH] =?UTF-8?q?-=20Swagger=20spec=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20GW=20=EA=B8=B0=EC=A4=80=20=EC=B9=98?= =?UTF-8?q?=ED=99=98=20=ED=86=B5=ED=95=A9=20-=20UI/=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=8F=99=EC=9D=BC=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?-=20ApiTesterFilter:=20gw=20=ED=98=B8=EC=B6=9C=20=EC=8B=9C=20ba?= =?UTF-8?q?se-url=20=EC=9E=AC=EC=A1=B0=EB=A6=BD=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20admin=20=EC=8A=A4=ED=8E=99=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=8B=9C=20=EC=8B=A4=EC=A3=BC=EC=86=8C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=EC=A0=9C=EA=B1=B0=20-=20=EC=9E=AC=EC=B9=98?= =?UTF-8?q?=ED=99=98=20=EC=9C=A0=EC=97=B0=EC=84=B1=20=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TestbedSpecController.java | 9 +- .../apps/apis/filter/ApiTesterFilter.java | 5 +- .../config/DjbTestbedGatewayProperty.java | 72 +++++++- .../controller/DjbTestbedSpecController.java | 29 +-- .../service/DjbTestbedSpecServerRewriter.java | 170 +++++------------- 5 files changed, 132 insertions(+), 153 deletions(-) diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java b/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java index b4a403b..ef363f3 100644 --- a/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java +++ b/src/main/java/com/eactive/apim/portal/apps/apis/controller/TestbedSpecController.java @@ -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); } } 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 d0d0d8b..c7496c9 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 @@ -203,11 +203,12 @@ public class ApiTesterFilter implements Filter { String targetUri; Map 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 의 쿼리스트링을 재부착 (기존 동작 유지) diff --git a/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java b/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java index 6aa9497..850f9de 100644 --- a/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java +++ b/src/main/java/com/eactive/apim/portal/djb/testbed/config/DjbTestbedGatewayProperty.java @@ -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)으로 결정한다는 뜻. + * + *

빈 문자열을 기본값으로 쓰면 {@code PTL_PROPERTY.property_value} 가 NOT NULL 이라 + * Oracle 에서 빈 문자열=NULL 로 저장되며 제약 위반이 나므로, 비어 있지 않은 표식을 쓴다.

+ */ + 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}) 기준. + * + *

PortalMock → {@code portalOrigin + /api/v1/oauth/token}(기존 mock 필터가 가로챔), + * GATEWAY → {@code specServerUrl + tokenPath}.

+ * + *

대외/대내 주소가 다른 환경에서 직접호출 모드({@code djb.gateway.token-use-proxy=false})는 + * 브라우저가 이 주소로 바로 나가므로 대외 주소여야 한다. 프록시 경유일 때는 이 값이 + * {@code original-url} 헤더로만 전달되고, 실제 forward 는 필터가 + * {@code baseUrl + tokenPath}(대내)로 수행한다.

*/ 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 만 떼어 대내 {@code base-url} 에 재조립한다 — 포탈 서버는 대외 주소로 나갈 수 없다. + * + *

PortalMock 모드는 실 GW 가 없으므로 원본을 그대로 쓴다. URL 파싱 실패 시에도 원본 유지(멱등).

+ */ + 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})에 노출할 주소. 응답유형과 무관하게 이 값 하나로 고정된다. + * + *

{@link #KEY_SPEC_SERVER_URL} 에 주소를 넣으면 그 값을 그대로 쓴다. PortalMock 모드처럼 + * {@code base-url} 이 실제 URL 이 아니어서 노출할 GW 주소를 코드가 알 수 없을 때 이 값으로 고정한다. + * 대외 공개 도메인이 내부 GW 주소와 다른 경우에도 사용한다.

+ * + *

{@value #DEFAULT_SPEC_SERVER_URL}(기본) 이면 {@link #resolveApiBaseUrl} 규칙을 그대로 따른다.

+ */ + 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; 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 07ad9b5..991d72b 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 @@ -48,28 +48,31 @@ public class DjbTestbedSpecController { @GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE) public ResponseEntity 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 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 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); } } 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 7d5017d..e91e7da 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 @@ -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)의 서버 주소를 제공 시점에 게이트웨이 주소로 치환한다. * - *
    - *
  • gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)
  • - *
  • mock : {@code ApiSpecInfo.mockUrl} 을 origin/path 로 분해 — origin 은 서버, path 는 paths 키로 - * 교체해 어댑터/오퍼레이션 경로가 뒤에 덧붙지 않게 한다
  • - *
  • sample(기본) : 요청 포탈 origin(scheme://host)
  • - *
+ *

spec 의 {@code servers} 는 응답유형(sample/mock/gw)과 무관하게 항상 GW 기준이다. + * spec 은 "이 API 를 실제로 어디로 호출하는가"의 공개 계약이고 그 기준은 게이트웨이 하나다. + * admin(app.js buildOpenApiSpec)도 같은 규칙으로 저장하며, 포탈은 제공 시점에 자기 환경의 + * {@link DjbTestbedGatewayProperty#resolveSpecServerUrl} 로 덮어써 저장 환경(dev)의 주소가 다른 환경까지 + * 따라오지 않게 한다. 노출 주소를 명시 고정해야 하면 + * {@code djb.gateway.spec-server-url}(PTL_PROPERTY) 에 넣는다. * - *

spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시). - * admin(app.js buildOpenApiSpec)은 저장 시 서버를 {@value #SERVER_SENTINEL} 로만 남기고 실주소를 굳히지 - * 않는다 — 저장본을 환경 독립으로 유지하기 위함이다. 실주소 결정은 이 서비스가 전담한다. + *

응답유형에 따른 차이는 테스트베드 동작에서만 난다 — Swagger UI 의 실행은 + * {@code /api/call-api} 프록시를 타고, {@code ApiTesterFilter} 가 응답유형별로 + * sample(저장 샘플 응답) / mock({@code mockUrl} forward) / gw(게이트웨이 forward) 를 가른다. + * 즉 표시 주소는 GW 로 고정이고 호출 대상만 갈린다. * - *

치환은 서버 값 유무와 무관하게 트리 조작으로 덮어쓴다. 과거 admin 이 실주소를 굳혀 저장한 - * spec(sentinel 없음)도 현재 환경 기준으로 정정되며, 저장 환경(dev)의 GW 주소가 따라오지 않는다. - * 다만 그 시절 mock 저장본은 {@code paths} 키가 이미 mock 경로로 바뀌어 있어 다운로드용 - * ({@link #rewriteServerToGateway}) 경로는 재저장 전까지 어댑터경로로 복원되지 않는다. + *

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

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), "/"}; - } - } - /** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */ public static String originOf(HttpServletRequest request) { if (request == null) {