snp-batch-validation/src/main/java/com/snp/batch/service/BypassCodeGenerator.java
HYOJIN 0132408ae3 feat: Bypass API 화면 개선 및 Swagger 그룹 분리 (#63)
프론트엔드:
- DTO 필드 입력 폼에 필드 번호(#N) 및 총 카운트 표시
- List View(테이블 뷰) 추가 및 카드/테이블 뷰 전환
- 실시간 검색 기능 추가 (도메인명, 표시명)

Swagger:
- GroupedOpenApi로 그룹 분리 (Batch Management, Bypass Config, Bypass API)
- 코드 생성 시 @Tag에 WebClient 종류 접두사 추가
- 코드 생성 시 @Parameter에 example 기본값 설정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 17:43:06 +09:00

457 lines
21 KiB
Java

package com.snp.batch.service;
import com.snp.batch.global.dto.CodeGenerationResult;
import com.snp.batch.global.model.BypassApiConfig;
import com.snp.batch.global.model.BypassApiField;
import com.snp.batch.global.model.BypassApiParam;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Collectors;
/**
* BYPASS API Java 소스 코드를 자동 생성하는 서비스.
* RiskBypassService / RiskController 패턴을 기반으로 DTO, Service, Controller를 생성합니다.
*/
@Slf4j
@Service
public class BypassCodeGenerator {
private static final String BASE_PACKAGE = "com.snp.batch.jobs.web";
/**
* BYPASS API 코드를 생성합니다.
*
* @param config 설정 정보
* @param params 파라미터 목록
* @param fields DTO 필드 목록
* @param force 기존 파일 덮어쓰기 여부
* @return 생성 결과
*/
public CodeGenerationResult generate(BypassApiConfig config,
List<BypassApiParam> params,
List<BypassApiField> fields,
boolean force) {
String projectRoot = System.getProperty("user.dir");
String domain = config.getDomainName();
String domainCapitalized = capitalize(domain);
String dtoCode = generateDtoCode(domain, domainCapitalized, fields);
String serviceCode = generateServiceCode(domain, domainCapitalized, config, params);
String controllerCode = generateControllerCode(domain, domainCapitalized, config, params);
String basePath = projectRoot + "/src/main/java/com/snp/batch/jobs/web/" + domain;
Path dtoPath = writeFile(basePath + "/dto/" + domainCapitalized + "BypassDto.java", dtoCode, force);
Path servicePath = writeFile(basePath + "/service/" + domainCapitalized + "BypassService.java", serviceCode, force);
Path controllerPath = writeFile(basePath + "/controller/" + domainCapitalized + "Controller.java", controllerCode, force);
log.info("코드 생성 완료 - domain: {}, dto: {}, service: {}, controller: {}",
domain, dtoPath, servicePath, controllerPath);
return CodeGenerationResult.builder()
.dtoPath(dtoPath.toString())
.servicePath(servicePath.toString())
.controllerPath(controllerPath.toString())
.message("코드 생성 완료. 서버를 재시작하면 새 API가 활성화됩니다.")
.build();
}
/**
* DTO 코드 생성.
* 각 field에 대해 @JsonProperty + private 필드를 생성합니다.
*/
private String generateDtoCode(String domain, String domainCap, List<BypassApiField> fields) {
String packageName = BASE_PACKAGE + "." + domain + ".dto";
boolean needsLocalDateTime = fields.stream()
.anyMatch(f -> "LocalDateTime".equals(f.getFieldType()));
StringBuilder imports = new StringBuilder();
imports.append("import com.fasterxml.jackson.annotation.JsonProperty;\n");
imports.append("import lombok.AllArgsConstructor;\n");
imports.append("import lombok.Builder;\n");
imports.append("import lombok.Getter;\n");
imports.append("import lombok.NoArgsConstructor;\n");
imports.append("import lombok.Setter;\n");
if (needsLocalDateTime) {
imports.append("import java.time.LocalDateTime;\n");
}
StringBuilder fieldLines = new StringBuilder();
for (BypassApiField field : fields) {
String jsonProp = field.getJsonProperty() != null ? field.getJsonProperty() : field.getFieldName();
fieldLines.append(" @JsonProperty(\"").append(jsonProp).append("\")\n");
fieldLines.append(" private ").append(field.getFieldType()).append(" ").append(field.getFieldName()).append(";\n\n");
}
return """
package {{PACKAGE}};
{{IMPORTS}}
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class {{CLASS_NAME}} {
{{FIELDS}}}
"""
.replace("{{PACKAGE}}", packageName)
.replace("{{IMPORTS}}", imports.toString())
.replace("{{CLASS_NAME}}", domainCap + "BypassDto")
.replace("{{FIELDS}}", fieldLines.toString());
}
/**
* Service 코드 생성.
* BaseBypassService를 상속하여 GET/POST, LIST/SINGLE 조합에 맞는 fetch 메서드를 생성합니다.
*/
private String generateServiceCode(String domain, String domainCap,
BypassApiConfig config, List<BypassApiParam> params) {
String packageName = BASE_PACKAGE + "." + domain + ".service";
String dtoPackage = BASE_PACKAGE + "." + domain + ".dto";
String dtoClass = domainCap + "BypassDto";
boolean isList = "LIST".equalsIgnoreCase(config.getResponseType());
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
String returnType = isList ? "List<" + dtoClass + ">" : dtoClass;
String methodName = "get" + domainCap + "Data";
String fetchMethod = buildFetchMethodCall(config, params, isList, isPost);
String methodParams = buildMethodParams(params);
String listImport = isList ? "import java.util.List;\n" : "";
return """
package {{PACKAGE}};
import {{DTO_IMPORT}};
import com.snp.batch.common.web.service.BaseBypassService;
{{LIST_IMPORT}}import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
/**
* {{DISPLAY_NAME}} bypass 서비스
* 외부 Maritime API에서 데이터를 실시간 조회하여 그대로 반환
*/
@Service
public class {{DOMAIN_CAP}}BypassService extends BaseBypassService<{{DTO_CLASS}}> {
public {{DOMAIN_CAP}}BypassService(
@Qualifier("{{WEBCLIENT_BEAN}}") WebClient webClient) {
super(webClient, "{{EXTERNAL_PATH}}", "{{DISPLAY_NAME}}",
new ParameterizedTypeReference<>() {},
new ParameterizedTypeReference<>() {});
}
/**
* {{DISPLAY_NAME}} 데이터를 조회합니다.
*
* @return {{DISPLAY_NAME}}
*/
public {{RETURN_TYPE}} {{METHOD_NAME}}({{METHOD_PARAMS}}) {
{{FETCH_METHOD}}
}
}
"""
.replace("{{PACKAGE}}", packageName)
.replace("{{DTO_IMPORT}}", dtoPackage + "." + dtoClass)
.replace("{{LIST_IMPORT}}", listImport)
.replace("{{DISPLAY_NAME}}", config.getDisplayName())
.replace("{{DOMAIN_CAP}}", domainCap)
.replace("{{DTO_CLASS}}", dtoClass)
.replace("{{WEBCLIENT_BEAN}}", config.getWebclientBean())
.replace("{{EXTERNAL_PATH}}", config.getExternalPath())
.replace("{{RETURN_TYPE}}", returnType)
.replace("{{METHOD_NAME}}", methodName)
.replace("{{METHOD_PARAMS}}", methodParams)
.replace("{{FETCH_METHOD}}", fetchMethod);
}
/**
* Controller 코드 생성.
* BaseBypassController를 상속하여 GET/POST 엔드포인트를 생성합니다.
*/
private String generateControllerCode(String domain, String domainCap,
BypassApiConfig config, List<BypassApiParam> params) {
String packageName = BASE_PACKAGE + "." + domain + ".controller";
String dtoPackage = BASE_PACKAGE + "." + domain + ".dto";
String servicePackage = BASE_PACKAGE + "." + domain + ".service";
String dtoClass = domainCap + "BypassDto";
String serviceClass = domainCap + "BypassService";
String serviceField = Character.toLowerCase(serviceClass.charAt(0)) + serviceClass.substring(1);
boolean isList = "LIST".equalsIgnoreCase(config.getResponseType());
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
String responseGeneric = isList ? "List<" + dtoClass + ">" : dtoClass;
String mappingAnnotation = isPost ? "@PostMapping" : "@GetMapping";
String listImport = isList ? "import java.util.List;\n" : "";
String paramAnnotations = buildControllerParamAnnotations(params);
String methodParams = buildMethodParams(params);
String serviceCallArgs = buildServiceCallArgs(params);
String pathVariableImport = params.stream().anyMatch(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
? "import org.springframework.web.bind.annotation.PathVariable;\n" : "";
String requestParamImport = params.stream().anyMatch(p -> "QUERY".equalsIgnoreCase(p.getParamIn()))
? "import org.springframework.web.bind.annotation.RequestParam;\n" : "";
String requestBodyImport = isPost
? "import org.springframework.web.bind.annotation.RequestBody;\n" : "";
String mappingImport = isPost
? "import org.springframework.web.bind.annotation.PostMapping;\n"
: "import org.springframework.web.bind.annotation.GetMapping;\n";
String mappingPath = buildMappingPath(params, config.getExternalPath());
String requestMappingPath = "/api/" + domain;
return """
package {{PACKAGE}};
import {{DTO_IMPORT}};
import {{SERVICE_IMPORT}};
import com.snp.batch.common.web.ApiResponse;
import com.snp.batch.common.web.controller.BaseBypassController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
{{LIST_IMPORT}}import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
{{MAPPING_IMPORT}}{{PATH_VARIABLE_IMPORT}}{{REQUEST_PARAM_IMPORT}}{{REQUEST_BODY_IMPORT}}
/**
* {{DISPLAY_NAME}} bypass API
* S&P Maritime API에서 데이터를 실시간 조회하여 그대로 반환
*/
@RestController
@RequestMapping("{{REQUEST_MAPPING_PATH}}")
@RequiredArgsConstructor
@Tag(name = "{{DOMAIN_CAP}}", description = "{{TAG_PREFIX}} {{DISPLAY_NAME}} bypass API")
public class {{DOMAIN_CAP}}Controller extends BaseBypassController {
private final {{SERVICE_CLASS}} {{SERVICE_FIELD}};
@Operation(
summary = "{{DISPLAY_NAME}} 조회",
description = "S&P API에서 {{DISPLAY_NAME}} 데이터를 요청하고 응답을 그대로 반환합니다."
)
{{MAPPING_ANNOTATION}}
public ResponseEntity<ApiResponse<{{RESPONSE_GENERIC}}>> get{{DOMAIN_CAP}}Data({{PARAM_ANNOTATIONS}}) {
return execute(() -> {{SERVICE_FIELD}}.get{{DOMAIN_CAP}}Data({{SERVICE_CALL_ARGS}}));
}
}
"""
.replace("{{PACKAGE}}", packageName)
.replace("{{DTO_IMPORT}}", dtoPackage + "." + dtoClass)
.replace("{{SERVICE_IMPORT}}", servicePackage + "." + serviceClass)
.replace("{{LIST_IMPORT}}", listImport)
.replace("{{PATH_VARIABLE_IMPORT}}", pathVariableImport)
.replace("{{REQUEST_PARAM_IMPORT}}", requestParamImport)
.replace("{{REQUEST_BODY_IMPORT}}", requestBodyImport)
.replace("{{MAPPING_IMPORT}}", mappingImport)
.replace("{{TAG_PREFIX}}", getTagPrefix(config.getWebclientBean()))
.replace("{{DISPLAY_NAME}}", config.getDisplayName())
.replace("{{DOMAIN_CAP}}", domainCap)
.replace("{{REQUEST_MAPPING_PATH}}", requestMappingPath)
.replace("{{SERVICE_CLASS}}", serviceClass)
.replace("{{SERVICE_FIELD}}", serviceField)
.replace("{{MAPPING_ANNOTATION}}", mappingAnnotation + mappingPath)
.replace("{{RESPONSE_GENERIC}}", responseGeneric)
.replace("{{PARAM_ANNOTATIONS}}", paramAnnotations.isEmpty() ? "" : paramAnnotations)
.replace("{{SERVICE_CALL_ARGS}}", serviceCallArgs);
}
/**
* fetch 메서드 호출 코드 생성
*/
private String buildFetchMethodCall(BypassApiConfig config, List<BypassApiParam> params,
boolean isList, boolean isPost) {
List<BypassApiParam> queryParams = params.stream()
.filter(p -> "QUERY".equalsIgnoreCase(p.getParamIn()))
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.toList();
StringBuilder uriBuilder = new StringBuilder();
uriBuilder.append("uri -> uri.path(getApiPath())");
for (BypassApiParam p : queryParams) {
uriBuilder.append("\n .queryParam(\"")
.append(p.getParamName()).append("\", ").append(p.getParamName()).append(")");
}
uriBuilder.append("\n .build()");
String fetchName;
if (isPost) {
fetchName = isList ? "fetchPostList" : "fetchPostOne";
BypassApiParam bodyParam = params.stream()
.filter(p -> "BODY".equalsIgnoreCase(p.getParamIn()))
.findFirst().orElse(null);
String bodyArg = bodyParam != null ? bodyParam.getParamName() : "null";
return "return " + fetchName + "(" + bodyArg + ", " + uriBuilder + ");";
} else {
fetchName = isList ? "fetchGetList" : "fetchGetOne";
return "return " + fetchName + "(" + uriBuilder + ");";
}
}
/**
* 메서드 파라미터 목록 생성 (Java 타입 + 이름)
*/
private String buildMethodParams(List<BypassApiParam> params) {
return params.stream()
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.map(p -> toJavaType(p.getParamType()) + " " + p.getParamName())
.collect(Collectors.joining(", "));
}
/**
* 서비스 호출 인자 목록 생성
*/
private String buildServiceCallArgs(List<BypassApiParam> params) {
return params.stream()
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.map(BypassApiParam::getParamName)
.collect(Collectors.joining(", "));
}
/**
* Controller 메서드 파라미터 어노테이션 포함 목록 생성
*/
private String buildControllerParamAnnotations(List<BypassApiParam> params) {
if (params.isEmpty()) {
return "";
}
return params.stream()
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.map(p -> {
String description = p.getDescription() != null ? p.getDescription() : p.getParamName();
String javaType = toJavaType(p.getParamType());
String paramName = p.getParamName();
String example = getDefaultExample(p.getParamType());
return switch (p.getParamIn().toUpperCase()) {
case "PATH" -> "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n"
+ " @PathVariable " + javaType + " " + paramName;
case "BODY" -> "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n"
+ " @RequestBody " + javaType + " " + paramName;
default -> {
String required = Boolean.TRUE.equals(p.getRequired()) ? "true" : "false";
yield "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n"
+ " @RequestParam(required = " + required + ") " + javaType + " " + paramName;
}
};
})
.collect(Collectors.joining(",\n "));
}
/**
* @GetMapping / @PostMapping에 붙는 경로 생성
* externalPath의 마지막 세그먼트를 내부 경로로 사용 + PATH 파라미터 추가
* 예: /RiskAndCompliance/CompliancesByImos → ("/CompliancesByImos")
* PATH 파라미터 imo 추가 시 → ("/CompliancesByImos/{imo}")
*/
private String buildMappingPath(List<BypassApiParam> params, String externalPath) {
// externalPath에서 마지막 세그먼트 추출
String endpointSegment = "";
if (externalPath != null && !externalPath.isEmpty()) {
String[] segments = externalPath.split("/");
if (segments.length > 0) {
endpointSegment = "/" + segments[segments.length - 1];
}
}
// PATH 파라미터 추가
List<BypassApiParam> pathParams = params.stream()
.filter(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.toList();
String pathSuffix = pathParams.stream()
.map(p -> "{" + p.getParamName() + "}")
.collect(Collectors.joining("/", pathParams.isEmpty() ? "" : "/", ""));
String fullPath = endpointSegment + pathSuffix;
if (fullPath.isEmpty()) {
return "";
}
return "(\"" + fullPath + "\")";
}
/**
* 파일을 지정 경로에 씁니다.
*
* @param path 파일 경로
* @param content 파일 내용
* @param force 덮어쓰기 허용 여부
* @return 생성된 파일 경로
*/
private Path writeFile(String path, String content, boolean force) {
Path filePath = Path.of(path);
if (Files.exists(filePath) && !force) {
throw new IllegalStateException("파일이 이미 존재합니다: " + path + " (덮어쓰려면 force 옵션을 사용하세요)");
}
try {
Files.createDirectories(filePath.getParent());
Files.writeString(filePath, content, StandardCharsets.UTF_8);
log.info("파일 생성: {}", filePath);
} catch (IOException e) {
throw new RuntimeException("파일 쓰기 실패: " + path, e);
}
return filePath;
}
/**
* webclientBean 이름 → Swagger @Tag description 접두사 변환
*/
private String getTagPrefix(String webclientBean) {
if (webclientBean == null) {
return "[Ship API]";
}
return switch (webclientBean) {
case "maritimeAisApiWebClient" -> "[AIS API]";
case "maritimeServiceApiWebClient" -> "[Service API]";
default -> "[Ship API]";
};
}
/**
* paramType → Swagger @Parameter example 기본값 결정
*/
private String getDefaultExample(String paramType) {
if (paramType == null) {
return "9876543";
}
return switch (paramType.toUpperCase()) {
case "INTEGER" -> "0";
case "LONG" -> "0";
case "BOOLEAN" -> "true";
default -> "9876543";
};
}
private String capitalize(String s) {
if (s == null || s.isEmpty()) {
return s;
}
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
/**
* param_type → Java 타입 변환
*/
private String toJavaType(String paramType) {
if (paramType == null) {
return "String";
}
return switch (paramType.toUpperCase()) {
case "INTEGER" -> "Integer";
case "LONG" -> "Long";
case "BOOLEAN" -> "Boolean";
default -> "String";
};
}
}