- 2FA 수신자 결정적 해시 컬럼 추가 및 조회/중복 처리 개선
- Swagger UI i18n 한글 패치 파일 추가 - Swagger UI 응답/스니펫 패널 커스텀화 및 코드 생성 기능 추가
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
-- =============================================================================
|
||||
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
|
||||
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
|
||||
--
|
||||
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
|
||||
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
|
||||
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
|
||||
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
|
||||
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
|
||||
--
|
||||
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
|
||||
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
|
||||
|
||||
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
|
||||
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
|
||||
|
||||
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
|
||||
|
||||
COMMIT;
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.eactive.apim.portal.djb.testbed.advice;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedAuthController;
|
||||
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedSpecController;
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 테스트베드 두 컨트롤러 한정 예외 → JSON 변환.
|
||||
* (qna 로컬 핸들러와 동일한 {@code {code, message}} 포맷.)
|
||||
*/
|
||||
@RestControllerAdvice(assignableTypes = {DjbTestbedAuthController.class, DjbTestbedSpecController.class})
|
||||
public class DjbTestbedExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DjbUnsupportedAuthTypeException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("code", "UNSUPPORTED_AUTHTYPE");
|
||||
body.put("message", ex.getMessage());
|
||||
body.put("authtype", ex.getAuthtype());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("code", "FORBIDDEN");
|
||||
body.put("message", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 테스트베드 게이트웨이 설정을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.gateway.})
|
||||
* 에서 읽는 얇은 접근자. 별도 property-injection 인프라 대신 기존
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 를 직접 사용한다(사용자 지시).
|
||||
*
|
||||
* <p>{@code getOrCreateProperty} 는 DB row 미존재 시 default 로 INSERT(그룹 존재 시)하지만
|
||||
* <em>공백 값 보정은 하지 않으므로</em>, 여기 {@link #resolve} 에서 {@code isBlank → default} 폴백을 둔다.
|
||||
*
|
||||
* <p>비-Spring 컴포넌트인 {@code ApiTesterFilter}(@WebFilter)는
|
||||
* {@code ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class)} 로 접근한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedGatewayProperty {
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_BASE_URL = "djb.gateway.base-url";
|
||||
public static final String KEY_TOKEN_PATH = "djb.gateway.token-path";
|
||||
public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header";
|
||||
public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header";
|
||||
|
||||
public static final String DEFAULT_BASE_URL = "PortalMock";
|
||||
/** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */
|
||||
public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token";
|
||||
public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key";
|
||||
/** 게이트웨이 API 호출 시 액세스 토큰 헤더명 (가이드 v0.2: Authorization → X-AUTH-TOKEN). */
|
||||
public static final String DEFAULT_OAUTH_HEADER = "X-AUTH-TOKEN";
|
||||
|
||||
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
||||
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
|
||||
|
||||
public String baseUrl() {
|
||||
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
||||
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
||||
}
|
||||
|
||||
public String tokenPath() {
|
||||
return resolve(KEY_TOKEN_PATH, DEFAULT_TOKEN_PATH,
|
||||
"OAuth 토큰 발급 경로 (GW 모드에서만 사용)");
|
||||
}
|
||||
|
||||
public String apiKeyHeader() {
|
||||
return resolve(KEY_API_KEY_HEADER, DEFAULT_API_KEY_HEADER,
|
||||
"API Key 전달 헤더명");
|
||||
}
|
||||
|
||||
public String oauthHeader() {
|
||||
return resolve(KEY_OAUTH_HEADER, DEFAULT_OAUTH_HEADER,
|
||||
"OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)");
|
||||
}
|
||||
|
||||
public DjbGatewayMode resolveGatewayMode() {
|
||||
return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 토큰 발급 URL.
|
||||
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
|
||||
* GATEWAY → {@code baseUrl + tokenPath}.
|
||||
*/
|
||||
public String resolveTokenUrl(String portalOrigin) {
|
||||
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
|
||||
}
|
||||
return stripTrailingSlash(baseUrl()) + tokenPath();
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
return StringUtils.hasText(value) ? value.trim() : defaultValue;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.eactive.apim.portal.djb.testbed.controller;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 테스트베드 인증 컨텍스트/시크릿 REST.
|
||||
* 컨트롤러 진입은 인증 없이 허용하되(비대상자도 context 를 받아야 함),
|
||||
* 실제 인증/역할/소유권 검증은 {@link DjbTestbedAuthService} 가 수행한다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/djb/testbed/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedAuthController {
|
||||
|
||||
private final DjbTestbedAuthService authService;
|
||||
|
||||
@GetMapping("/context")
|
||||
public ResponseEntity<DjbTestbedContextDto> context(HttpServletRequest request) {
|
||||
return ResponseEntity.ok(authService.buildContext(resolveOrigin(request)));
|
||||
}
|
||||
|
||||
@GetMapping("/credentials/{clientId}/secret")
|
||||
public ResponseEntity<DjbCredentialSecretDto> credentialSecret(@PathVariable String clientId,
|
||||
@RequestParam String apiId) {
|
||||
DjbCredentialSecretDto secret = authService.getCredentialSecret(clientId, apiId);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.header(HttpHeaders.PRAGMA, "no-cache")
|
||||
.body(secret);
|
||||
}
|
||||
|
||||
/** scheme://host[:port] (기본 포트는 생략). PortalMock 토큰 URL 구성에 사용. */
|
||||
private String resolveOrigin(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String host = request.getServerName();
|
||||
int port = request.getServerPort();
|
||||
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
|
||||
return scheme + "://" + host + (defaultPort ? "" : ":" + port);
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.eactive.apim.portal.djb.testbed.controller;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* testbed spec(swagger.json)에 AUTHTYPE 기반 securityScheme 를 주입해 반환.
|
||||
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(enrich 대상 외).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/djb/testbed/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecController {
|
||||
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedAuthService authService;
|
||||
private final DjbSwaggerSpecEnricher enricher;
|
||||
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id) throws IOException {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return ResponseEntity.ok(content);
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
DjbAuthType authType = authService.resolveAuthType(id);
|
||||
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
||||
return ResponseEntity.ok(enriched);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 테스트베드 앱 셀렉트에 노출할 앱(PTL_CREDENTIAL) 1건. 시크릿은 포함하지 않는다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbCredentialOptionDto {
|
||||
|
||||
/** PTL_CREDENTIAL.clientid */
|
||||
private String clientId;
|
||||
|
||||
/** 사용자에게 보여줄 앱명(PTL_CREDENTIAL.clientname) */
|
||||
private String clientName;
|
||||
|
||||
/** "0": 차단, "1": 정상 */
|
||||
private String appStatus;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 선택한 앱의 시크릿 단건 응답(캐시 금지 · 로그 마스킹 대상).
|
||||
* OAUTH 시 {@code clientSecret} 은 client_secret, API_KEY 시 API Key 값이다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbCredentialSecretDto {
|
||||
|
||||
private String clientId;
|
||||
|
||||
private String clientSecret;
|
||||
|
||||
private DjbAuthType authType;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* {@code GET /djb/testbed/auth/context} 응답. 시크릿은 포함하지 않는다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbTestbedContextDto {
|
||||
|
||||
/** 인증 정보 제공 대상자 여부 */
|
||||
private boolean eligible;
|
||||
|
||||
/** 비대상 사유 코드: ANONYMOUS / INDIVIDUAL_USER / NO_CREDENTIAL (대상자면 null) */
|
||||
private String reason;
|
||||
|
||||
/** 사용자가 선택할 수 있는 앱 목록 */
|
||||
private List<DjbCredentialOptionDto> credentials;
|
||||
|
||||
/** 게이트웨이 정보(시크릿 제외) */
|
||||
private GatewayInfo gateway;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class GatewayInfo {
|
||||
private DjbGatewayMode mode;
|
||||
private String baseUrl;
|
||||
private String tokenUrl;
|
||||
private String apiKeyHeader;
|
||||
private String oauthTokenHeader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
|
||||
/**
|
||||
* 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다.
|
||||
* <ul>
|
||||
* <li>{@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}</li>
|
||||
* <li>{@code api_key}/{@code apikey} → {@link #API_KEY}</li>
|
||||
* <li>그 외 → {@link DjbUnsupportedAuthTypeException}</li>
|
||||
* </ul>
|
||||
* ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.)
|
||||
*/
|
||||
public enum DjbAuthType {
|
||||
OAUTH,
|
||||
API_KEY;
|
||||
|
||||
public static DjbAuthType fromAuthtype(String authtype) {
|
||||
if (authtype == null) {
|
||||
throw new DjbUnsupportedAuthTypeException("null");
|
||||
}
|
||||
String normalized = authtype.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "oauth":
|
||||
case "oauth2":
|
||||
case "ca":
|
||||
return OAUTH;
|
||||
case "api_key":
|
||||
case "apikey":
|
||||
return API_KEY;
|
||||
default:
|
||||
throw new DjbUnsupportedAuthTypeException(authtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
/**
|
||||
* 테스트베드 호출이 향하는 대상 모드.
|
||||
* <ul>
|
||||
* <li>{@code PORTAL_MOCK} — {@code djb.gateway.base-url} 이 문자열 "PortalMock" 인 경우.
|
||||
* {@code ApiTesterFilter} 가 mock 토큰/샘플 응답을 반환한다.</li>
|
||||
* <li>{@code GATEWAY} — 그 외(실제 게이트웨이 URL). 토큰 발급 요청을 실 게이트웨이로 forward.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum DjbGatewayMode {
|
||||
PORTAL_MOCK,
|
||||
GATEWAY
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.eactive.apim.portal.djb.testbed.exception;
|
||||
|
||||
/**
|
||||
* TSEAIHE01.AUTHTYPE 값이 테스트베드가 지원하는 인증 체계(oauth/api_key)로
|
||||
* 매핑되지 않을 때 발생. {@link com.eactive.apim.portal.djb.testbed.advice.DjbTestbedExceptionHandler}
|
||||
* 에서 400 응답으로 변환된다.
|
||||
*/
|
||||
public class DjbUnsupportedAuthTypeException extends RuntimeException {
|
||||
|
||||
private final String authtype;
|
||||
|
||||
public DjbUnsupportedAuthTypeException(String authtype) {
|
||||
super("지원하지 않는 인증 체계: " + authtype);
|
||||
this.authtype = authtype;
|
||||
}
|
||||
|
||||
public String getAuthtype() {
|
||||
return authtype;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* testbed spec(JSON)에 인증 체계를 주입한다.
|
||||
*
|
||||
* <p>현재 데이터는 <b>OpenAPI 3.0</b>(auto-gen {@code "openapi":"3.0.0"})이므로
|
||||
* {@code components.securitySchemes} + 글로벌 {@code security} 에 주입한다.
|
||||
* (원본이 Swagger 2.0 이면 {@code securityDefinitions} 로 폴백.)
|
||||
*
|
||||
* <p>게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 이 아니라 커스텀 헤더
|
||||
* {@code X-AUTH-TOKEN: Bearer ...} 로 받으므로(DJERP_4000 v0.2), OAuth 도 API_KEY 와 동일하게
|
||||
* <b>apiKey-in-header</b> 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가
|
||||
* {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer <token>"} 으로 authorize 한다.
|
||||
*
|
||||
* <p>원본의 {@code paths}, {@code x-*} 확장, 기타 {@code components} 는 트리 조작으로 보존한다
|
||||
* (문자열 치환 금지).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DjbSwaggerSpecEnricher {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
public static final String OAUTH_SCHEME = "djbOAuth";
|
||||
public static final String API_KEY_SCHEME = "djbApiKey";
|
||||
|
||||
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
|
||||
JsonNode parsed = objectMapper.readTree(specJson);
|
||||
if (!parsed.isObject()) {
|
||||
// 예상 밖 형태면 원본 유지(멱등)
|
||||
return specJson;
|
||||
}
|
||||
ObjectNode root = (ObjectNode) parsed;
|
||||
|
||||
String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME;
|
||||
ObjectNode scheme = buildApiKeyScheme(authType);
|
||||
|
||||
boolean swagger2 = root.has("swagger") && !root.has("openapi");
|
||||
if (swagger2) {
|
||||
getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme);
|
||||
} else {
|
||||
ObjectNode components = getOrCreateObject(root, "components");
|
||||
getOrCreateObject(components, "securitySchemes").set(schemeName, scheme);
|
||||
}
|
||||
|
||||
// 글로벌 security 요구사항 (apiKey 스킴은 빈 스코프 배열)
|
||||
ArrayNode security = objectMapper.createArrayNode();
|
||||
ObjectNode requirement = objectMapper.createObjectNode();
|
||||
requirement.set(schemeName, objectMapper.createArrayNode());
|
||||
security.add(requirement);
|
||||
root.set("security", security);
|
||||
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
|
||||
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
|
||||
String headerName = (authType == DjbAuthType.OAUTH)
|
||||
? gatewayProperty.oauthHeader()
|
||||
: gatewayProperty.apiKeyHeader();
|
||||
ObjectNode scheme = objectMapper.createObjectNode();
|
||||
scheme.put("type", "apiKey");
|
||||
scheme.put("in", "header");
|
||||
scheme.put("name", headerName);
|
||||
return scheme;
|
||||
}
|
||||
|
||||
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
|
||||
JsonNode node = parent.get(field);
|
||||
if (node != null && node.isObject()) {
|
||||
return (ObjectNode) node;
|
||||
}
|
||||
ObjectNode created = objectMapper.createObjectNode();
|
||||
parent.set(field, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialOptionDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto.GatewayInfo;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 테스트베드 인증 컨텍스트 구성 · 앱 시크릿 조회 · authtype 매핑.
|
||||
* 인증/역할/소유권 검증은 이 서비스에서 수행한다({@code SecurityFilterChain} 은 URL 인가 블록이 없음).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedAuthService {
|
||||
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final EAIMessageRepository eaiMessageRepository;
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
private static final String APP_STATUS_ACTIVE = "1";
|
||||
|
||||
/**
|
||||
* 비대상 사유: {@code ANONYMOUS}(비로그인) / {@code INDIVIDUAL_USER}(ROLE_USER) /
|
||||
* {@code NO_CREDENTIAL}(appstatus='1' 앱 0건). 하나라도 해당하면 {@code eligible=false}.
|
||||
*/
|
||||
public DjbTestbedContextDto buildContext(String portalOrigin) {
|
||||
GatewayInfo gateway = buildGatewayInfo(portalOrigin);
|
||||
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return notEligible("ANONYMOUS", gateway);
|
||||
}
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
return notEligible("INDIVIDUAL_USER", gateway);
|
||||
}
|
||||
|
||||
String orgId = SecurityUtil.getUserOrg().getId();
|
||||
List<DjbCredentialOptionDto> options = credentialRepository.findAllByOrgid(orgId).stream()
|
||||
.filter(c -> APP_STATUS_ACTIVE.equals(c.getAppstatus()))
|
||||
.map(this::toOption)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (options.isEmpty()) {
|
||||
return notEligible("NO_CREDENTIAL", gateway);
|
||||
}
|
||||
|
||||
return DjbTestbedContextDto.builder()
|
||||
.eligible(true)
|
||||
.reason(null)
|
||||
.credentials(options)
|
||||
.gateway(gateway)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 앱의 시크릿 단건. 로그인 + 비-ROLE_USER + 본인 소유(orgId 일치) 검증 후 반환.
|
||||
* 소유 불일치/비대상은 {@link AccessDeniedException}(→ 403).
|
||||
*/
|
||||
public DjbCredentialSecretDto getCredentialSecret(String clientId, String apiId) {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
throw new AccessDeniedException("로그인이 필요합니다.");
|
||||
}
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
throw new AccessDeniedException("개인사용자는 이용할 수 없습니다.");
|
||||
}
|
||||
|
||||
String orgId = SecurityUtil.getUserOrg().getId();
|
||||
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||
.orElseThrow(() -> new AccessDeniedException("본인 소유의 앱이 아닙니다."));
|
||||
|
||||
DjbAuthType authType = resolveAuthType(apiId);
|
||||
|
||||
return DjbCredentialSecretDto.builder()
|
||||
.clientId(credential.getClientid())
|
||||
.clientSecret(credential.getClientsecret())
|
||||
.authType(authType)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* apiId(=TSEAIHE01.eaisvcname, admin 입력 컨벤션)로 authtype 조회 후 매핑.
|
||||
* 미매핑/미존재는 {@code DjbUnsupportedAuthTypeException}(→ 400).
|
||||
*/
|
||||
public DjbAuthType resolveAuthType(String apiId) {
|
||||
String authtype = eaiMessageRepository.findById(apiId)
|
||||
.map(EAIMessageEntity::getAuthtype)
|
||||
.orElse(null);
|
||||
return DjbAuthType.fromAuthtype(authtype);
|
||||
}
|
||||
|
||||
private DjbCredentialOptionDto toOption(Credential c) {
|
||||
return DjbCredentialOptionDto.builder()
|
||||
.clientId(c.getClientid())
|
||||
.clientName(c.getClientname())
|
||||
.appStatus(c.getAppstatus())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayInfo buildGatewayInfo(String portalOrigin) {
|
||||
return GatewayInfo.builder()
|
||||
.mode(gatewayProperty.resolveGatewayMode())
|
||||
.baseUrl(gatewayProperty.baseUrl())
|
||||
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
|
||||
.apiKeyHeader(gatewayProperty.apiKeyHeader())
|
||||
.oauthTokenHeader(gatewayProperty.oauthHeader())
|
||||
.build();
|
||||
}
|
||||
|
||||
private DjbTestbedContextDto notEligible(String reason, GatewayInfo gateway) {
|
||||
return DjbTestbedContextDto.builder()
|
||||
.eligible(false)
|
||||
.reason(reason)
|
||||
.credentials(Collections.emptyList())
|
||||
.gateway(gateway)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*!
|
||||
* djb-swagger-i18n.js
|
||||
* Swagger UI 영문 라벨을 한글로 치환하는 i18n 패치 (방법 B - DOM patch)
|
||||
* 배포 경로 권장: static/plugins/swaggerUI/djb-swagger-i18n.js
|
||||
* index.html 의 swagger-initializer.js 직전 또는 직후에 <script> 로 로드
|
||||
*
|
||||
* ?lang=en 쿼리스트링이 있으면 패치 비활성화 (원문 보기)
|
||||
* 매핑 카탈로그는 01-i18n-mapping/i18n-strings.csv 와 동기 유지
|
||||
*/
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
// ── 비활성화 옵션 ──
|
||||
if (/[?&]lang=en\b/.test(window.location.search)) return;
|
||||
|
||||
// ── 매핑 (key 는 디버깅용, 실제 비교는 EN 문자열 일치) ──
|
||||
var I18N_MAP = {
|
||||
// P0 — OAuth2 모달 본문
|
||||
'auth.modal.title': { en: 'Available authorizations', ko: '사용 가능한 인증' },
|
||||
'auth.scopes.description': {
|
||||
en: 'Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.',
|
||||
ko: '스코프는 최종 사용자를 대신하여 애플리케이션이 데이터에 접근할 수 있는 권한 수준을 부여합니다. 각 API는 하나 이상의 스코프를 선언할 수 있습니다.'
|
||||
},
|
||||
'auth.scopes.swagger_grant': {
|
||||
en: 'API requires the following scopes. Select which ones you want to grant to Swagger UI.',
|
||||
ko: '이 API 는 아래 스코프가 필요합니다. Swagger UI 에 부여할 스코프를 선택하세요.'
|
||||
},
|
||||
'auth.flow.clientCredentials': { en: 'OAuth2 client credentials flow', ko: 'OAuth2 클라이언트 자격증명 흐름' },
|
||||
'auth.flow.implicit': { en: 'OAuth2 implicit flow', ko: 'OAuth2 암묵적 흐름' },
|
||||
'auth.flow.password': { en: 'OAuth2 password flow', ko: 'OAuth2 비밀번호 흐름' },
|
||||
'auth.flow.authorizationCode': { en: 'OAuth2 authorization code flow', ko: 'OAuth2 인가 코드 흐름' },
|
||||
'auth.field.tokenUrl': { en: 'Token URL:', ko: '토큰 URL:' },
|
||||
'auth.field.flow': { en: 'Flow:', ko: '흐름:' },
|
||||
'auth.field.clientId': { en: 'client_id:', ko: '클라이언트 ID (client_id):' },
|
||||
'auth.field.clientSecret': { en: 'client_secret:',ko: '클라이언트 시크릿 (client_secret):' },
|
||||
'auth.field.scopes': { en: 'Scopes:', ko: '스코프:' },
|
||||
'auth.action.selectAll': { en: 'select all', ko: '모두 선택' },
|
||||
'auth.action.selectNone': { en: 'select none', ko: '선택 해제' },
|
||||
'auth.action.authorize': { en: 'Authorize', ko: '인증' },
|
||||
'auth.action.close': { en: 'Close', ko: '닫기' },
|
||||
'auth.action.logout': { en: 'Logout', ko: '로그아웃' },
|
||||
'auth.modal.authorized': { en: 'authorized', ko: '인증됨' },
|
||||
|
||||
// P1 — 기타 인증 흐름
|
||||
'auth.scheme.apiKey': { en: 'API key', ko: 'API 키' },
|
||||
'auth.field.name': { en: 'name:', ko: '이름:' },
|
||||
'auth.field.in': { en: 'in:', ko: '위치:' },
|
||||
'auth.field.value': { en: 'Value:', ko: '값:' },
|
||||
'basic.field.username': { en: 'Username:',ko: '사용자 ID:' },
|
||||
'basic.field.password': { en: 'Password:',ko: '비밀번호:' },
|
||||
|
||||
// P2 — 오퍼레이션 / 파라미터 / 응답 UI 라벨 (PoC ko-i18n.js 카탈로그와 동기)
|
||||
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
|
||||
'op.cancel': { en: 'Cancel', ko: '취소' },
|
||||
'op.execute': { en: 'Execute', ko: '실행' },
|
||||
'op.clear': { en: 'Clear', ko: '지우기' },
|
||||
'op.reset': { en: 'Reset', ko: '초기화' },
|
||||
'op.parameters': { en: 'Parameters', ko: '파라미터' },
|
||||
'op.parameter': { en: 'Parameter', ko: '파라미터' },
|
||||
'op.name': { en: 'Name', ko: '이름' },
|
||||
'op.description': { en: 'Description', ko: '설명' },
|
||||
'op.value': { en: 'Value', ko: '값' },
|
||||
'op.type': { en: 'Type', ko: '타입' },
|
||||
'op.requestBody': { en: 'Request body', ko: '요청 본문' },
|
||||
'op.requestUrl': { en: 'Request URL', ko: '요청 URL' },
|
||||
'op.responses': { en: 'Responses', ko: '응답' },
|
||||
'op.response': { en: 'Response', ko: '응답' },
|
||||
'op.responseBody': { en: 'Response body', ko: '응답 본문' },
|
||||
'op.responseHeaders': { en: 'Response headers', ko: '응답 헤더' },
|
||||
'op.responseCType': { en: 'Response content type', ko: '응답 컨텐츠 타입' },
|
||||
'op.code': { en: 'Code', ko: '코드' },
|
||||
'op.details': { en: 'Details', ko: '상세' },
|
||||
'op.schema': { en: 'Schema', ko: '스키마' },
|
||||
'op.schemas': { en: 'Schemas', ko: '스키마' },
|
||||
'op.exampleValue': { en: 'Example Value', ko: '예시 값' },
|
||||
'op.noParameters': { en: 'No parameters', ko: '파라미터 없음' },
|
||||
'op.required': { en: 'required', ko: '필수' },
|
||||
'op.servers': { en: 'Servers', ko: '서버' },
|
||||
'op.serverResponse': { en: 'Server response', ko: '서버 응답' },
|
||||
'op.loading': { en: 'Loading...', ko: '불러오는 중...' },
|
||||
'op.curlUpper': { en: 'Curl', ko: 'cURL' },
|
||||
'op.curlLower': { en: 'curl', ko: 'cURL' },
|
||||
'op.editValue': { en: 'Edit Value', ko: '값 편집' },
|
||||
'op.model': { en: 'Model', ko: '모델' },
|
||||
'op.snippets': { en: 'Snippets', ko: '스니펫' },
|
||||
'op.mediaType': { en: 'Media type', ko: '미디어 타입' },
|
||||
'op.controlsAccept': { en: 'Controls Accept header.', ko: 'Accept 헤더를 제어합니다.' },
|
||||
'op.sendEmptyValue': { en: 'Send empty value', ko: '빈 값 전송' },
|
||||
'op.hide': { en: 'Hide', ko: '숨기기' },
|
||||
'op.show': { en: 'Show', ko: '표시' }
|
||||
};
|
||||
|
||||
// 빠른 lookup 을 위해 EN → KO 인덱스 사전 생성
|
||||
var EN_TO_KO = Object.create(null);
|
||||
Object.keys(I18N_MAP).forEach(function (k) {
|
||||
var e = I18N_MAP[k];
|
||||
EN_TO_KO[e.en] = e.ko;
|
||||
});
|
||||
|
||||
// ── 치환 대상 노드 식별 + 치환 ──
|
||||
function translateNode(node) {
|
||||
if (!node) return;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
var raw = node.nodeValue;
|
||||
if (!raw) return;
|
||||
var trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
// 1) 완전 일치 (가장 안전)
|
||||
if (EN_TO_KO[trimmed]) {
|
||||
node.nodeValue = raw.replace(trimmed, EN_TO_KO[trimmed]);
|
||||
return;
|
||||
}
|
||||
// 2) 라벨 colon 패턴: "Token URL:" 같이 끝이 ":" 인 짧은 라벨
|
||||
// (불필요 — 1번 완전 일치로 대부분 커버)
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
|
||||
|
||||
// input placeholder / button title 도 일부 케이스에 대비
|
||||
if (node.tagName === 'INPUT' && node.placeholder && EN_TO_KO[node.placeholder.trim()]) {
|
||||
node.placeholder = EN_TO_KO[node.placeholder.trim()];
|
||||
}
|
||||
|
||||
var i, child = node.childNodes, len = child.length;
|
||||
for (i = 0; i < len; i++) translateNode(child[i]);
|
||||
}
|
||||
|
||||
function translateAll() {
|
||||
var root = document.getElementById('swagger-ui') || document.body;
|
||||
translateNode(root);
|
||||
}
|
||||
|
||||
// ── MutationObserver — SwaggerUI 가 동적으로 DOM 을 다시 그릴 때마다 재적용 ──
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var m = mutations[i];
|
||||
if (m.addedNodes && m.addedNodes.length) {
|
||||
for (var j = 0; j < m.addedNodes.length; j++) translateNode(m.addedNodes[j]);
|
||||
}
|
||||
if (m.type === 'characterData') {
|
||||
translateNode(m.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function start() {
|
||||
translateAll();
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})(window);
|
||||
@@ -0,0 +1,187 @@
|
||||
/*!
|
||||
* DjbSwaggerResponse — Swagger UI 기본 라이브 응답 표(.live-responses-table)를 CSS 로
|
||||
* 숨기고, 실행(Execute)한 operation 블록 안에 커스텀 응답 패널을 세로 적층으로 렌더한다.
|
||||
* (요청 스니펫 .request-snippets 은 같은 .responses-wrapper 안에 있으므로 유지)
|
||||
*
|
||||
* ┌─────────────────────────────────────────────┐
|
||||
* │ 응답 헤더 (Table) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답 본문 (JSON Pretty / Raw) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답코드 칩 + Response Time(ms) │
|
||||
* └─────────────────────────────────────────────┘
|
||||
*
|
||||
* 포탈 testbed 는 API 하나에 operation 이 여러 개일 수 있으므로, 전역 단일 그리드가
|
||||
* 아니라 Execute 를 누른 opblock 안에 그리드를 만든다(클릭 캡처로 대상 특정).
|
||||
*
|
||||
* 연결:
|
||||
* const ui = SwaggerUIBundle({
|
||||
* responseInterceptor: function (res) { DjbSwaggerResponse.renderResponse(res); return res; }
|
||||
* });
|
||||
* window.ui = ui;
|
||||
* DjbSwaggerResponse.attach('#swagger-ui'); // Execute 클릭 캡처 + 타이머 등록
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var startTs = 0;
|
||||
var targetOpblock = null;
|
||||
|
||||
function now() {
|
||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function statusKind(status) {
|
||||
if (status >= 200 && status < 300) return "ok";
|
||||
if (status >= 300 && status < 400) return "warn";
|
||||
if (status >= 400) return "err";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function prettyJson(text) {
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip neutral">대기</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderHeadersTable(headers) {
|
||||
var keys = Object.keys(headers || {});
|
||||
if (!keys.length) return '<div class="empty">헤더가 없습니다.</div>';
|
||||
var rows = keys.map(function (k) {
|
||||
return '<tr><td class="hdr-name">' + escapeHtml(k) + '</td>'
|
||||
+ '<td class="hdr-val">' + escapeHtml(headers[k]) + '</td></tr>';
|
||||
}).join("");
|
||||
return '<table class="hdr-table"><thead><tr><th>이름</th><th>값</th></tr></thead>'
|
||||
+ '<tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function bodyTabs(rawText) {
|
||||
var pretty = prettyJson(rawText);
|
||||
var hasJson = pretty != null;
|
||||
var jsonTab = hasJson ? '<button type="button" class="body-tab active" data-tab="json">JSON (Pretty)</button>' : "";
|
||||
var rawTab = '<button type="button" class="body-tab' + (hasJson ? "" : " active") + '" data-tab="raw">Raw</button>';
|
||||
var jsonPanel = hasJson ? '<pre class="body-panel mono active" data-tab="json">' + escapeHtml(pretty) + '</pre>' : "";
|
||||
var rawPanel = '<pre class="body-panel mono' + (hasJson ? "" : " active") + '" data-tab="raw">' + escapeHtml(rawText || "") + '</pre>';
|
||||
return '<div class="body-tabs" role="tablist">' + jsonTab + rawTab + '</div>'
|
||||
+ '<div class="body-panels">' + jsonPanel + rawPanel + '</div>';
|
||||
}
|
||||
|
||||
function attachTabHandlers(root) {
|
||||
if (root.__djbTabBound) return;
|
||||
root.__djbTabBound = true;
|
||||
root.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest && e.target.closest(".body-tab");
|
||||
if (!btn) return;
|
||||
var panels = root.querySelectorAll(".body-panel");
|
||||
var tabs = root.querySelectorAll(".body-tab");
|
||||
tabs.forEach(function (t) { t.classList.toggle("active", t === btn); });
|
||||
var which = btn.getAttribute("data-tab");
|
||||
panels.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-tab") === which); });
|
||||
});
|
||||
}
|
||||
|
||||
// 대상 opblock(없으면 열려있는/첫 opblock) 안 .opblock-body 에 그리드 1개 확보.
|
||||
// 항상 opblock-body 최하단으로 이동시킨다: Execute 시점엔 responses-wrapper(요청
|
||||
// 스니펫)가 아직 없어 그리드가 그 위에 붙지만, 응답 후 재호출 시 최하단으로 옮겨
|
||||
// "Execute → 요청 스니펫 → 응답 그리드" 순서를 보장한다.
|
||||
function gridFor(opblock) {
|
||||
var host = opblock
|
||||
|| document.querySelector("#swagger-ui .opblock.is-open")
|
||||
|| document.querySelector("#swagger-ui .opblock");
|
||||
if (!host) return null;
|
||||
var body = host.querySelector(".opblock-body");
|
||||
if (!body) return null; // 접힘 상태에는 마운트하지 않음 (bare opblock 오배치 방지)
|
||||
var grid = body.querySelector(".djb-response-grid");
|
||||
if (!grid) {
|
||||
grid = document.createElement("div");
|
||||
grid.className = "djb-response-grid djb-empty";
|
||||
grid.innerHTML = renderEmpty();
|
||||
attachTabHandlers(grid);
|
||||
}
|
||||
body.appendChild(grid); // 기존 노드면 최하단으로 이동
|
||||
return grid;
|
||||
}
|
||||
|
||||
// Execute 클릭 시점에 대상 opblock 확정 + 타이머 시작 + 빈 그리드 표시.
|
||||
function onExecuteClick(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) return;
|
||||
var btn = t.closest(".btn.execute");
|
||||
if (!btn) return;
|
||||
targetOpblock = btn.closest(".opblock");
|
||||
startTs = now();
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (grid) {
|
||||
grid.classList.add("djb-empty");
|
||||
grid.classList.remove("djb-error");
|
||||
grid.innerHTML = renderEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} res Swagger UI responseInterceptor 결과
|
||||
* { status, statusText, headers, text, data, ... }
|
||||
*/
|
||||
function renderResponse(res) {
|
||||
if (!res) return;
|
||||
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
|
||||
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
|
||||
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
|
||||
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
||||
var ms = Math.max(0, Math.round(now() - startTs));
|
||||
var status = res.status || 0;
|
||||
var statusText = res.statusText || "";
|
||||
var headers = res.headers || {};
|
||||
var rawText = (typeof res.text === "string") ? res.text
|
||||
: (typeof res.data === "string") ? res.data
|
||||
: (res.data != null ? JSON.stringify(res.data) : "");
|
||||
var op = targetOpblock;
|
||||
|
||||
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
|
||||
setTimeout(function () {
|
||||
var grid = gridFor(op);
|
||||
if (!grid) return;
|
||||
var kind = statusKind(status);
|
||||
grid.classList.remove("djb-empty", "djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ renderHeadersTable(headers) + '</div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ bodyTabs(rawText) + '</div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip ' + kind + '">응답코드: ' + escapeHtml(status)
|
||||
+ (statusText ? " " + escapeHtml(statusText) : "") + '</span>'
|
||||
+ '<span class="time">Response Time: ' + ms + ' ms</span>'
|
||||
+ '</div>';
|
||||
attachTabHandlers(grid);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
|
||||
function attach(rootSel) {
|
||||
var root = document.querySelector(rootSel || "#swagger-ui");
|
||||
if (!root || root.__djbRespBound) return;
|
||||
root.__djbRespBound = true;
|
||||
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보
|
||||
root.addEventListener("click", onExecuteClick, true);
|
||||
}
|
||||
|
||||
global.DjbSwaggerResponse = { attach: attach, renderResponse: renderResponse };
|
||||
})(window);
|
||||
@@ -0,0 +1,251 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippetPanel — Execute 없이 페이지 진입 즉시 요청 스니펫을 보여주는 상시 패널.
|
||||
* (PoC rinjae-works/swagger-poc/js/snippet-panel.js 를 포탈용으로 이식)
|
||||
*
|
||||
* Swagger UI 5 의 내장 .request-snippets 는 Execute 이후에만, 그리고 응답영역 안에
|
||||
* 렌더되므로 CSS 로 숨기고, 동일한 window.SnippetGenerators 로 직접 패널을 만들어
|
||||
* operation 의 Execute 버튼(.btn-group) 바로 아래에 마운트한다. Body/헤더 변경 시 자동 갱신.
|
||||
*
|
||||
* 전제: 이 페이지는 항상 1개 API(단일 operation) 노출 — spec 의 첫 path/method 사용.
|
||||
* spec 은 window.ui 에서 지연 조회한다.
|
||||
*
|
||||
* 연결: SwaggerUIBundle 의 onComplete 에서 DjbSwaggerSnippetPanel.mount() 호출.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var TABS = [
|
||||
{ key: "curl_bash", title: "cURL (Bash)", gen: "curlBash" },
|
||||
{ key: "curl_powershell", title: "cURL (PowerShell)", gen: "curlPwsh" },
|
||||
{ key: "curl_cmd", title: "cURL (CMD)", gen: "curlCmd" },
|
||||
{ key: "csharp", title: "C#", gen: "csharp" },
|
||||
{ key: "ecma5", title: "ECMA5 (JavaScript)", gen: "ecma5" },
|
||||
{ key: "java", title: "Java", gen: "javaOkhttp" },
|
||||
{ key: "python", title: "Python", gen: "python" }
|
||||
];
|
||||
|
||||
var activeKey = "curl_bash";
|
||||
var rootSel = "#swagger-ui";
|
||||
var observer = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// window.ui 에서 해석된 spec(json) 조회.
|
||||
function getSpecJson() {
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (ui && typeof ui.spec === "function") {
|
||||
var s = ui.spec();
|
||||
if (s && typeof s.toJS === "function") {
|
||||
var o = s.toJS();
|
||||
return o.json || o;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Path 1 + Method 1 전제 하에 단일 operation 반환.
|
||||
function getSingleOperation(spec) {
|
||||
var paths = (spec && spec.paths) ? spec.paths : {};
|
||||
var pk = Object.keys(paths)[0];
|
||||
if (!pk) return null;
|
||||
var ops = paths[pk];
|
||||
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
|
||||
if (!mk) return null;
|
||||
return { path: pk, method: mk, operation: ops[mk] };
|
||||
}
|
||||
|
||||
function readHeaderInput(name) {
|
||||
var row = document.querySelector('tr[data-param-name="' + name + '"][data-param-in="header"]');
|
||||
if (row) {
|
||||
var input = row.querySelector('input[type="text"], input:not([type])');
|
||||
if (input && input.value) return input.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Authorize 모달 사용 후에만 표시되는 인증 헤더 추출.
|
||||
function readAuthHeaders() {
|
||||
var headers = {};
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (!ui || !ui.authSelectors) return headers;
|
||||
var auths = ui.authSelectors.authorized();
|
||||
if (!auths) return headers;
|
||||
var js = auths.toJS();
|
||||
Object.keys(js).forEach(function (key) {
|
||||
var a = js[key];
|
||||
if (!a || !a.schema) return;
|
||||
var scheme = a.schema;
|
||||
if (scheme.type === "oauth2") {
|
||||
var tok = a.token && a.token.access_token;
|
||||
if (tok) headers["Authorization"] = "Bearer " + tok;
|
||||
} else if (scheme.type === "apiKey" && scheme.in === "header") {
|
||||
if (a.value) headers[scheme.name] = a.value;
|
||||
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
|
||||
if (a.value) headers["Authorization"] = "Bearer " + a.value;
|
||||
}
|
||||
});
|
||||
} catch (e) { /* noop */ }
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
var spec = getSpecJson();
|
||||
var opInfo = spec && getSingleOperation(spec);
|
||||
if (!opInfo) return null;
|
||||
var op = opInfo.operation;
|
||||
var server = (spec.servers && spec.servers[0] && spec.servers[0].url) || "";
|
||||
var url = server + opInfo.path;
|
||||
|
||||
var headers = {};
|
||||
(op.parameters || []).forEach(function (p) {
|
||||
if (p.in !== "header") return;
|
||||
var val = readHeaderInput(p.name)
|
||||
|| (p.example !== undefined ? String(p.example) : null)
|
||||
|| (p.schema && p.schema.example !== undefined ? String(p.schema.example) : null);
|
||||
if (val) headers[p.name] = val;
|
||||
});
|
||||
|
||||
var body = "";
|
||||
if (op.requestBody && op.requestBody.content) {
|
||||
var cts = Object.keys(op.requestBody.content);
|
||||
var ct = cts[0] || "application/json";
|
||||
headers["Content-Type"] = ct;
|
||||
var ta = document.querySelector(rootSel + " textarea.body-param__text");
|
||||
if (ta && ta.value) {
|
||||
body = ta.value;
|
||||
} else if (op.requestBody.content[ct] && op.requestBody.content[ct].example !== undefined) {
|
||||
try { body = JSON.stringify(op.requestBody.content[ct].example, null, 2); } catch (e) { body = ""; }
|
||||
}
|
||||
}
|
||||
headers["accept"] = headers["accept"] || "application/json";
|
||||
var auth = readAuthHeaders();
|
||||
Object.keys(auth).forEach(function (k) { headers[k] = auth[k]; });
|
||||
|
||||
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
|
||||
}
|
||||
|
||||
function panelEl() { return document.querySelector("#djb-snippet-panel"); }
|
||||
|
||||
function renderInto(el) {
|
||||
if (!el) return;
|
||||
var gens = global.SnippetGenerators;
|
||||
var code;
|
||||
if (!gens) {
|
||||
code = "// 스니펫 생성기 로드 대기…";
|
||||
} else {
|
||||
try {
|
||||
var req = buildRequest();
|
||||
if (!req) { code = "// 스펙 로딩 중…"; }
|
||||
else {
|
||||
var active = TABS.filter(function (t) { return t.key === activeKey; })[0] || TABS[0];
|
||||
code = gens[active.gen](req);
|
||||
}
|
||||
} catch (e) { code = "// 스니펫 생성 오류: " + e.message; }
|
||||
}
|
||||
var tabsHtml = TABS.map(function (t) {
|
||||
return '<button type="button" class="djb-sn-tab ' + (t.key === activeKey ? "active" : "") + '" data-key="' + t.key + '">' + t.title + '</button>';
|
||||
}).join("");
|
||||
el.innerHTML =
|
||||
'<div class="djb-sn-header">'
|
||||
+ '<h4>요청 스니펫</h4>'
|
||||
+ '<span class="djb-sn-hint">(실행 없이 즉시 미리보기 — Body / 헤더 변경 시 자동 갱신)</span>'
|
||||
+ '<button type="button" class="djb-sn-copy" data-action="copy">복사</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="djb-sn-tabs" role="tablist">' + tabsHtml + '</div>'
|
||||
+ '<pre class="djb-sn-code mono">' + escapeHtml(code) + '</pre>';
|
||||
}
|
||||
|
||||
// 옵저버 재진입(무한 루프) 방지: 우리 DOM 변경 중에는 관찰 일시 중지.
|
||||
function withObserverPaused(fn) {
|
||||
if (observer) observer.disconnect();
|
||||
try { fn(); }
|
||||
finally {
|
||||
if (observer) {
|
||||
var root = document.querySelector(rootSel);
|
||||
if (root) observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 버튼(.btn-group) 바로 아래에 패널 확보.
|
||||
// - operation 이 접혀 opblock-body/실행영역이 없으면: 잘못 남은 패널을 제거하고 중단
|
||||
// (bare .opblock 에 붙어 opblock-summary 뒤로 튀는 문제 방지). 다음 펼침 때 재생성.
|
||||
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
|
||||
function ensureMounted() {
|
||||
var op = document.querySelector(rootSel + " .opblock.is-open")
|
||||
|| document.querySelector(rootSel + " .opblock");
|
||||
var body = op && op.querySelector(".opblock-body");
|
||||
var anchor = body && (body.querySelector(".btn-group") || body.querySelector(".execute-wrapper"));
|
||||
var existing = panelEl();
|
||||
|
||||
if (!body || !anchor) {
|
||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing.parentNode !== anchor.parentNode || existing.previousElementSibling !== anchor) {
|
||||
anchor.parentNode.insertBefore(existing, anchor.nextSibling);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = document.createElement("div");
|
||||
panel.id = "djb-snippet-panel";
|
||||
panel.className = "djb-snippet-panel";
|
||||
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
|
||||
renderInto(panel);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
var tab = e.target.closest && e.target.closest("#djb-snippet-panel .djb-sn-tab");
|
||||
if (tab) {
|
||||
activeKey = tab.getAttribute("data-key");
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
return;
|
||||
}
|
||||
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
|
||||
if (copy) {
|
||||
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
|
||||
if (code && global.navigator && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(code.innerText).then(function () {
|
||||
copy.textContent = "복사됨";
|
||||
setTimeout(function () { copy.textContent = "복사"; }, 1200);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.matches) return;
|
||||
if (t.matches("textarea.body-param__text") || t.matches("tr[data-param-name] input")) {
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
}
|
||||
}
|
||||
|
||||
function mount(opts) {
|
||||
rootSel = (opts && opts.rootSel) || "#swagger-ui";
|
||||
var root = document.querySelector(rootSel);
|
||||
if (!root) return;
|
||||
if (!root.__djbSnBound) {
|
||||
root.__djbSnBound = true;
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("input", onInput);
|
||||
observer = new MutationObserver(function () {
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
}
|
||||
|
||||
global.DjbSwaggerSnippetPanel = { mount: mount, render: function () { renderInto(panelEl()); } };
|
||||
})(window);
|
||||
@@ -0,0 +1,223 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippets — Swagger UI 의 SnippetGeneratorPlugin 에 등록되는 7개 언어
|
||||
* 클라이언트 코드 생성기. 서버측 /api/sample_code/{language} 호출을 대체한다.
|
||||
*
|
||||
* cURL (Bash) / cURL (PowerShell) / cURL (CMD) / C# / ECMA5 (JS) / Java / Python
|
||||
*
|
||||
* 입력: Swagger UI requestSnippetGenerator 가 넘기는 Immutable request.
|
||||
* .toJS() 로 plain object 변환 후 url/method/headers/body 사용.
|
||||
* (showMutatedRequest:false 이므로 프록시 URL 이 아닌 실제 대상 요청이 넘어온다)
|
||||
* 출력: string (코드 본문)
|
||||
*
|
||||
* 전역: window.SnippetGeneratorPlugin (Swagger plugins 배열에 등록),
|
||||
* window.SnippetGenerators (개별 생성 함수 접근용)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function unwrap(req) {
|
||||
if (!req) return {};
|
||||
if (typeof req.toJS === "function") return req.toJS();
|
||||
return req;
|
||||
}
|
||||
|
||||
function getCt(headers) {
|
||||
for (const k of Object.keys(headers || {})) {
|
||||
if (k.toLowerCase() === "content-type") return headers[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bodyAsString(body) {
|
||||
if (body === undefined || body === null) return "";
|
||||
if (typeof body === "string") return body;
|
||||
try { return JSON.stringify(body); } catch (e) { return String(body); }
|
||||
}
|
||||
|
||||
function escapeSingleQuote(s) { return String(s).replace(/'/g, "'\\''"); }
|
||||
function escapeDoubleQuote(s) { return String(s).replace(/"/g, '\\"'); }
|
||||
|
||||
// ─── cURL (Bash) ────────────────────────────────────────────────────────────
|
||||
function curlBash(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const lines = [`curl --location --request ${r.method || "GET"} '${escapeSingleQuote(r.url)}'`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` --header '${escapeSingleQuote(k)}: ${escapeSingleQuote(headers[k])}'`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
lines.push(` --data-raw '${escapeSingleQuote(body)}'`);
|
||||
}
|
||||
return lines.join(" \\\n");
|
||||
}
|
||||
|
||||
// ─── cURL (PowerShell) ─────────────────────────────────────────────────────
|
||||
function curlPwsh(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
// 백틱(`) 줄 연속자
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" `\n");
|
||||
}
|
||||
|
||||
// ─── cURL (Windows CMD) ────────────────────────────────────────────────────
|
||||
function curlCmd(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" ^\n");
|
||||
}
|
||||
|
||||
// ─── C# (RestSharp) ─────────────────────────────────────────────────────────
|
||||
function csharp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`using RestSharp;`,
|
||||
``,
|
||||
`var client = new RestClient("${escapeDoubleQuote(r.url)}");`,
|
||||
`var request = new RestRequest(Method.${method});`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`request.AddHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
const ct = getCt(headers) || "application/json";
|
||||
lines.push(`request.AddParameter("${escapeDoubleQuote(ct)}", @"${body.replace(/"/g, '""')}", ParameterType.RequestBody);`);
|
||||
}
|
||||
lines.push(`IRestResponse response = client.Execute(request);`);
|
||||
lines.push(`Console.WriteLine(response.Content);`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── ECMA5 / JavaScript (XHR) ──────────────────────────────────────────────
|
||||
function ecma5(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`var xhr = new XMLHttpRequest();`,
|
||||
`xhr.withCredentials = false;`,
|
||||
``,
|
||||
`xhr.addEventListener("readystatechange", function () {`,
|
||||
` if (this.readyState === 4) {`,
|
||||
` console.log(this.responseText);`,
|
||||
` }`,
|
||||
`});`,
|
||||
``,
|
||||
`xhr.open("${method}", "${escapeDoubleQuote(r.url)}");`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`xhr.setRequestHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
// ES5 라 template literal 사용 X — JSON.stringify 로 안전한 문자열 리터럴 생성
|
||||
lines.push(`xhr.send(${JSON.stringify(body)});`);
|
||||
} else {
|
||||
lines.push(`xhr.send(null);`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Java (OkHttp) ─────────────────────────────────────────────────────────
|
||||
function javaOkhttp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const ct = getCt(headers) || "application/json";
|
||||
const lines = [
|
||||
`OkHttpClient client = new OkHttpClient().newBuilder().build();`,
|
||||
`MediaType mediaType = MediaType.parse("${escapeDoubleQuote(ct)}");`,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`RequestBody body = RequestBody.create(mediaType, "${escapeDoubleQuote(body)}");`);
|
||||
}
|
||||
lines.push(`Request request = new Request.Builder()`);
|
||||
lines.push(` .url("${escapeDoubleQuote(r.url)}")`);
|
||||
if (body) {
|
||||
lines.push(` .method("${method}", body)`);
|
||||
} else {
|
||||
lines.push(` .method("${method}", null)`);
|
||||
}
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` .addHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}")`);
|
||||
}
|
||||
lines.push(` .build();`);
|
||||
lines.push(`Response response = client.newCall(request).execute();`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Python (requests) ─────────────────────────────────────────────────────
|
||||
function python(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toLowerCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
|
||||
const headerEntries = Object.keys(headers).map(
|
||||
k => ` "${escapeDoubleQuote(k)}": "${escapeDoubleQuote(headers[k])}"`
|
||||
);
|
||||
const lines = [
|
||||
`import requests`,
|
||||
``,
|
||||
`url = "${escapeDoubleQuote(r.url)}"`,
|
||||
``,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`payload = ${JSON.stringify(body)}`);
|
||||
}
|
||||
lines.push(`headers = {`);
|
||||
if (headerEntries.length) lines.push(headerEntries.join(",\n"));
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
if (body) {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers, data=payload)`);
|
||||
} else {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers)`);
|
||||
}
|
||||
lines.push(`print(response.text)`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Swagger UI 플러그인 등록 페이로드 ──────────────────────────────────────
|
||||
const SnippetGeneratorPlugin = function () {
|
||||
return {
|
||||
fn: {
|
||||
// curl_bash / curl_powershell / curl_cmd 는 Swagger UI 5 내장 기본 키와 동일하게
|
||||
// 맞춰 내장 생성기를 덮어쓴다. (curl_pwsh 등 다른 키를 쓰면 내장 PowerShell 탭이
|
||||
// 중복 표시됨)
|
||||
requestSnippetGenerator_curl_bash: curlBash,
|
||||
requestSnippetGenerator_curl_powershell: curlPwsh,
|
||||
requestSnippetGenerator_curl_cmd: curlCmd,
|
||||
requestSnippetGenerator_csharp: csharp,
|
||||
requestSnippetGenerator_ecma5: ecma5,
|
||||
requestSnippetGenerator_java: javaOkhttp,
|
||||
requestSnippetGenerator_python: python
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
global.SnippetGeneratorPlugin = SnippetGeneratorPlugin;
|
||||
global.SnippetGenerators = {
|
||||
curlBash, curlPwsh, curlCmd, csharp, ecma5, javaOkhttp, python
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,277 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
/* DJB Swagger Testbed overlay */
|
||||
/* - 기본 라이브 응답 표(.live-responses-table) 숨김 (스니펫은 유지) */
|
||||
/* - 실행한 operation 안에 세로 적층 커스텀 응답 패널(.djb-response-grid) */
|
||||
/* - D2Coding 우선 monospace fallback */
|
||||
/* 포탈 통합용. PoC(rinjae-works/swagger-poc/css/overlay.css) 에서 응답 파트만 */
|
||||
/* 발췌·클래스화(단일 ID → 다중 op 대응). */
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* D2Coding 은 시스템에 설치된 경우에만 사용 (local()). 미설치 시 fallback 체인 사용. */
|
||||
@font-face {
|
||||
font-family: "D2Coding";
|
||||
src: local("D2Coding"),
|
||||
local("D2Coding-Regular"),
|
||||
local("D2Coding ligature");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--djb-mono: "D2Coding", "Menlo", "Consolas", "Courier New", monospace;
|
||||
--djb-border: #d0d7de;
|
||||
--djb-bg: #ffffff;
|
||||
--djb-bg-alt: #f6f8fa;
|
||||
--djb-text: #1f2328;
|
||||
--djb-text-muted: #6e7781;
|
||||
--djb-ok: #1f883d;
|
||||
--djb-warn: #bf8700;
|
||||
--djb-err: #cf222e;
|
||||
--djb-accent: #0969da;
|
||||
}
|
||||
|
||||
/* ─── Swagger UI 기본 응답영역 통째 숨김 ──────────────────────────────────────
|
||||
요청 스니펫은 내장(.request-snippets, 실행 후·응답영역 내부·밝은 테마) 대신
|
||||
상시 다크 패널(#djb-snippet-panel, djb-swagger-snippet-panel.js)로 대체하고,
|
||||
응답 결과는 커스텀 그리드(.djb-response-grid)로 대체한다.
|
||||
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 그리드. */
|
||||
.swagger-ui .responses-wrapper { display: none !important; }
|
||||
.swagger-ui .request-snippets { display: none !important; }
|
||||
|
||||
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
|
||||
.swagger-ui #djb-snippet-panel {
|
||||
margin: 16px;
|
||||
padding: 12px 16px 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-hint {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--djb-bg-alt);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy:hover { background: #eaeef2; }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* 한글 라벨이 길어도 깨지지 않도록 */
|
||||
.swagger-ui .opblock .opblock-summary-path,
|
||||
.swagger-ui .opblock-tag,
|
||||
.swagger-ui table thead tr th,
|
||||
.swagger-ui .parameter__name,
|
||||
.swagger-ui .parameter__type {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* ─── 커스텀 응답 그리드: 헤더 → 본문 → 메타 세로 적층 (opblock 마다 1개) ── */
|
||||
.swagger-ui .djb-response-grid {
|
||||
margin: 16px;
|
||||
padding: 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"headers"
|
||||
"body"
|
||||
"meta";
|
||||
gap: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui .djb-response-grid.djb-error { border-color: var(--djb-err); background: #fff8f8; }
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-headers { grid-area: headers; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-body { grid-area: body; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-meta {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--djb-border);
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .empty {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Header 테이블 */
|
||||
.swagger-ui .djb-response-grid .hdr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table th,
|
||||
.swagger-ui .djb-response-grid .hdr-table td {
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-all;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table thead th {
|
||||
background: var(--djb-bg-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-name {
|
||||
font-weight: 600;
|
||||
color: var(--djb-text);
|
||||
width: 40%;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-val {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
|
||||
/* Body 탭 */
|
||||
.swagger-ui .djb-response-grid .body-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui .djb-response-grid .body-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .body-panels {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel.active { display: block; }
|
||||
.swagger-ui .djb-response-grid .mono { font-family: var(--djb-mono); }
|
||||
|
||||
/* Meta 칩 / 시간 */
|
||||
.swagger-ui .djb-response-grid .status-chip {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: var(--djb-mono);
|
||||
color: #fff;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .status-chip.ok { background: var(--djb-ok); }
|
||||
.swagger-ui .djb-response-grid .status-chip.warn { background: var(--djb-warn); }
|
||||
.swagger-ui .djb-response-grid .status-chip.err { background: var(--djb-err); }
|
||||
.swagger-ui .djb-response-grid .status-chip.neutral { background: var(--djb-text-muted); }
|
||||
|
||||
.swagger-ui .djb-response-grid .time {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text-muted);
|
||||
}
|
||||
|
||||
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
|
||||
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
|
||||
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code::selection,
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code ::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel ::selection {
|
||||
background: #2f5c9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DjbSwaggerSpecEnricherTest {
|
||||
|
||||
@Mock
|
||||
private DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private DjbSwaggerSpecEnricher enricher;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
enricher = new DjbSwaggerSpecEnricher(objectMapper, gatewayProperty);
|
||||
}
|
||||
|
||||
@Test
|
||||
void oauth_injects_apiKey_scheme_with_oauth_header_and_preserves_original() throws Exception {
|
||||
when(gatewayProperty.oauthHeader()).thenReturn("X-AUTH-TOKEN");
|
||||
String spec = "{\"openapi\":\"3.0.0\",\"info\":{\"title\":\"t\"},"
|
||||
+ "\"paths\":{\"/a\":{\"get\":{\"x-original-api-id\":\"API1\"}}}}";
|
||||
|
||||
String result = enricher.enrich(spec, DjbAuthType.OAUTH);
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
|
||||
JsonNode scheme = root.path("components").path("securitySchemes").path("djbOAuth");
|
||||
assertEquals("apiKey", scheme.path("type").asText());
|
||||
assertEquals("header", scheme.path("in").asText());
|
||||
assertEquals("X-AUTH-TOKEN", scheme.path("name").asText());
|
||||
assertTrue(root.path("security").get(0).has("djbOAuth"));
|
||||
// 원본 paths / x- 확장 보존
|
||||
assertEquals("API1", root.path("paths").path("/a").path("get").path("x-original-api-id").asText());
|
||||
}
|
||||
|
||||
@Test
|
||||
void apiKey_injects_apiKey_scheme_with_api_key_header() throws Exception {
|
||||
when(gatewayProperty.apiKeyHeader()).thenReturn("X-DJB-API-Key");
|
||||
String spec = "{\"openapi\":\"3.0.0\",\"paths\":{}}";
|
||||
|
||||
String result = enricher.enrich(spec, DjbAuthType.API_KEY);
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
|
||||
JsonNode scheme = root.path("components").path("securitySchemes").path("djbApiKey");
|
||||
assertEquals("apiKey", scheme.path("type").asText());
|
||||
assertEquals("header", scheme.path("in").asText());
|
||||
assertEquals("X-DJB-API-Key", scheme.path("name").asText());
|
||||
assertTrue(root.path("security").get(0).has("djbApiKey"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void swagger2_falls_back_to_securityDefinitions() throws Exception {
|
||||
when(gatewayProperty.apiKeyHeader()).thenReturn("X-DJB-API-Key");
|
||||
String spec = "{\"swagger\":\"2.0\",\"paths\":{}}";
|
||||
|
||||
String result = enricher.enrich(spec, DjbAuthType.API_KEY);
|
||||
JsonNode root = objectMapper.readTree(result);
|
||||
|
||||
assertEquals("X-DJB-API-Key",
|
||||
root.path("securityDefinitions").path("djbApiKey").path("name").asText());
|
||||
assertFalse(root.has("components"));
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalOrgDTO;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class DjbTestbedAuthServiceTest {
|
||||
|
||||
@Mock private CredentialRepository credentialRepository;
|
||||
@Mock private EAIMessageRepository eaiMessageRepository;
|
||||
@Mock private DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
private DjbTestbedAuthService service;
|
||||
|
||||
private static final String ORG_ID = "ORG1";
|
||||
private static final String CLIENT_ID = "CID";
|
||||
private static final String API_ID = "API1";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new DjbTestbedAuthService(credentialRepository, eaiMessageRepository, gatewayProperty);
|
||||
// 게이트웨이 정보(buildContext 모든 분기에서 구성)
|
||||
when(gatewayProperty.resolveGatewayMode()).thenReturn(DjbGatewayMode.PORTAL_MOCK);
|
||||
when(gatewayProperty.baseUrl()).thenReturn("PortalMock");
|
||||
when(gatewayProperty.resolveTokenUrl(anyString())).thenReturn("http://localhost/api/v1/oauth/token");
|
||||
when(gatewayProperty.apiKeyHeader()).thenReturn("X-DJB-API-Key");
|
||||
when(gatewayProperty.oauthHeader()).thenReturn("X-AUTH-TOKEN");
|
||||
}
|
||||
|
||||
private PortalAuthenticatedUser user(RoleCode roleCode) {
|
||||
PortalAuthenticatedUser u = new PortalAuthenticatedUser();
|
||||
u.setRoleCode(roleCode);
|
||||
return u;
|
||||
}
|
||||
|
||||
private PortalOrgDTO org() {
|
||||
PortalOrgDTO o = new PortalOrgDTO();
|
||||
o.setId(ORG_ID);
|
||||
return o;
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildContext_anonymous() {
|
||||
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
|
||||
sec.when(SecurityUtil::isAuthenticated).thenReturn(false);
|
||||
|
||||
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
|
||||
|
||||
assertFalse(ctx.isEligible());
|
||||
assertEquals("ANONYMOUS", ctx.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildContext_individualUser() {
|
||||
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
|
||||
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
|
||||
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_USER));
|
||||
|
||||
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
|
||||
|
||||
assertFalse(ctx.isEligible());
|
||||
assertEquals("INDIVIDUAL_USER", ctx.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildContext_noCredential() {
|
||||
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
|
||||
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
|
||||
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_USER));
|
||||
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
|
||||
when(credentialRepository.findAllByOrgid(ORG_ID)).thenReturn(Collections.emptyList());
|
||||
|
||||
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
|
||||
|
||||
assertFalse(ctx.isEligible());
|
||||
assertEquals("NO_CREDENTIAL", ctx.getReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void buildContext_eligible_onlyActiveApps() {
|
||||
Credential active = new Credential();
|
||||
active.setClientid(CLIENT_ID);
|
||||
active.setClientname("APP");
|
||||
active.setAppstatus("1");
|
||||
Credential blocked = new Credential();
|
||||
blocked.setClientid("CID2");
|
||||
blocked.setAppstatus("0");
|
||||
|
||||
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
|
||||
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
|
||||
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_MANAGER));
|
||||
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
|
||||
when(credentialRepository.findAllByOrgid(ORG_ID)).thenReturn(java.util.Arrays.asList(active, blocked));
|
||||
|
||||
DjbTestbedContextDto ctx = service.buildContext("http://localhost:8080");
|
||||
|
||||
assertTrue(ctx.isEligible());
|
||||
assertEquals(1, ctx.getCredentials().size());
|
||||
assertEquals(CLIENT_ID, ctx.getCredentials().get(0).getClientId());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCredentialSecret_success() {
|
||||
Credential cred = new Credential();
|
||||
cred.setClientid(CLIENT_ID);
|
||||
cred.setClientsecret("SECRET");
|
||||
EAIMessageEntity msg = Mockito.mock(EAIMessageEntity.class);
|
||||
when(msg.getAuthtype()).thenReturn("oauth");
|
||||
|
||||
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
|
||||
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
|
||||
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_USER));
|
||||
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
|
||||
when(credentialRepository.findByClientidAndOrgid(CLIENT_ID, ORG_ID)).thenReturn(Optional.of(cred));
|
||||
when(eaiMessageRepository.findById(API_ID)).thenReturn(Optional.of(msg));
|
||||
|
||||
DjbCredentialSecretDto dto = service.getCredentialSecret(CLIENT_ID, API_ID);
|
||||
|
||||
assertEquals("SECRET", dto.getClientSecret());
|
||||
assertEquals(DjbAuthType.OAUTH, dto.getAuthType());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCredentialSecret_notOwned_throwsAccessDenied() {
|
||||
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
|
||||
sec.when(SecurityUtil::isAuthenticated).thenReturn(true);
|
||||
sec.when(SecurityUtil::getPortalAuthenticatedUser).thenReturn(user(RoleCode.ROLE_CORP_USER));
|
||||
sec.when(SecurityUtil::getUserOrg).thenReturn(org());
|
||||
when(credentialRepository.findByClientidAndOrgid(CLIENT_ID, ORG_ID)).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> service.getCredentialSecret(CLIENT_ID, API_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void getCredentialSecret_anonymous_throwsAccessDenied() {
|
||||
try (MockedStatic<SecurityUtil> sec = Mockito.mockStatic(SecurityUtil.class)) {
|
||||
sec.when(SecurityUtil::isAuthenticated).thenReturn(false);
|
||||
|
||||
assertThrows(AccessDeniedException.class, () -> service.getCredentialSecret(CLIENT_ID, API_ID));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveAuthType_apiKey() {
|
||||
EAIMessageEntity msg = Mockito.mock(EAIMessageEntity.class);
|
||||
when(msg.getAuthtype()).thenReturn("api_key");
|
||||
when(eaiMessageRepository.findById(API_ID)).thenReturn(Optional.of(msg));
|
||||
|
||||
assertEquals(DjbAuthType.API_KEY, service.resolveAuthType(API_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveAuthType_missing_throwsUnsupported() {
|
||||
when(eaiMessageRepository.findById("APIX")).thenReturn(Optional.empty());
|
||||
|
||||
assertThrows(DjbUnsupportedAuthTypeException.class, () -> service.resolveAuthType("APIX"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user