snp-global/src/main/java/com/snp/batch/service/BypassCodeGenerator.java
HYOJIN fab03a31bb feat(swagger): Swagger 응답 스키마 자동 생성 및 API 문서 개선 (#14)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:40:49 +09:00

467 lines
23 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.BypassApiParam;
import lombok.RequiredArgsConstructor;
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.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* BYPASS API Java 소스 코드를 자동 생성하는 서비스.
* 모든 API는 RAW 모드(JsonNode 패스스루)로 생성됩니다.
* 같은 도메인의 N개 설정을 받아 N개의 Service와 1개의 Controller를 생성합니다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BypassCodeGenerator {
private final SwaggerSchemaGenerator swaggerSchemaGenerator;
private static final String BASE_PACKAGE = "com.snp.batch.jobs.web";
/**
* 같은 도메인의 BYPASS API 코드를 생성합니다.
* 엔드포인트별 Service를 각각 생성하고, Controller 1개에 모든 엔드포인트 메서드를 합칩니다.
* 모든 응답은 JsonNode로 패스스루됩니다 (DTO 없음).
*/
public CodeGenerationResult generate(List<BypassApiConfig> configs, boolean force) {
if (configs == null || configs.isEmpty()) {
throw new IllegalArgumentException("생성할 설정이 없습니다.");
}
String projectRoot = System.getProperty("user.dir");
String domain = configs.get(0).getDomainName();
String basePath = projectRoot + "/src/main/java/com/snp/batch/jobs/web/" + domain;
List<String> servicePaths = new ArrayList<>();
// Generate response/request DTOs from swagger.json
Map<Long, String> responseSchemaMap = new HashMap<>();
Map<Long, String> requestBodySchemaMap = new HashMap<>();
for (BypassApiConfig config : configs) {
String fqcn = swaggerSchemaGenerator.generateResponseDto(
config.getWebclientBean(), config.getExternalPath(), force);
if (fqcn != null) {
responseSchemaMap.put(config.getId(), fqcn);
config.setResponseSchemaClass(fqcn);
}
// POST 엔드포인트의 requestBody DTO 생성
if ("POST".equalsIgnoreCase(config.getHttpMethod())) {
String reqFqcn = swaggerSchemaGenerator.generateRequestBodyDto(
config.getWebclientBean(), config.getExternalPath(), force);
if (reqFqcn != null) {
requestBodySchemaMap.put(config.getId(), reqFqcn);
}
}
}
for (BypassApiConfig config : configs) {
String endpointName = config.getEndpointName();
String servicePath = basePath + "/service/" + endpointName + "Service.java";
if (!force && Files.exists(Path.of(servicePath))) {
log.info("Service 파일 이미 존재, 스킵: {}", servicePath);
servicePaths.add(servicePath);
} else {
String serviceCode = generateServiceCode(domain, endpointName, config, config.getParams());
Path serviceFilePath = writeFile(servicePath, serviceCode, true);
servicePaths.add(serviceFilePath.toString());
}
}
// Controller: 모든 엔드포인트를 합치므로 항상 재생성
String controllerCode = generateControllerCode(domain, configs, responseSchemaMap, requestBodySchemaMap);
String domainCapitalized = capitalize(domain);
Path controllerFilePath = writeFile(
basePath + "/controller/" + domainCapitalized + "Controller.java", controllerCode, true);
log.info("코드 생성 완료 - domain: {}, endpoints: {}, controller: {}",
domain, configs.stream().map(BypassApiConfig::getEndpointName).toList(), controllerFilePath);
return CodeGenerationResult.builder()
.controllerPath(controllerFilePath.toString())
.servicePaths(servicePaths)
.message("코드 생성 완료. 서버를 재시작하면 새 API가 활성화됩니다.")
.build();
}
/**
* Service 코드 생성 (RAW 모드).
* BaseBypassService<JsonNode>를 상속하여 fetchRawGet/fetchRawPost로 JsonNode를 반환합니다.
*/
private String generateServiceCode(String domain, String endpointName,
BypassApiConfig config, List<BypassApiParam> params) {
String packageName = BASE_PACKAGE + "." + domain + ".service";
String serviceClass = endpointName + "Service";
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
// POST인데 BODY 파라미터가 없으면 자동 추가
List<BypassApiParam> effectiveParams = new ArrayList<>(params);
if (isPost && effectiveParams.stream().noneMatch(p -> "BODY".equalsIgnoreCase(p.getParamIn()))) {
BypassApiParam autoBody = new BypassApiParam();
autoBody.setParamName("body");
autoBody.setParamType("STRING");
autoBody.setParamIn("BODY");
autoBody.setRequired(false);
autoBody.setDescription("요청 본문 (JSON)");
autoBody.setExample("{}");
autoBody.setSortOrder(999);
effectiveParams.add(autoBody);
}
String methodName = "get" + endpointName + "Data";
String fetchMethod = buildFetchMethodCall(config, effectiveParams, isPost);
String methodParams = buildMethodParams(effectiveParams);
return """
package {{PACKAGE}};
import com.fasterxml.jackson.databind.JsonNode;
import com.snp.batch.common.web.service.BaseBypassService;
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}} 서비스
* 외부 Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환
*/
@Service
public class {{SERVICE_CLASS}} extends BaseBypassService<JsonNode> {
public {{SERVICE_CLASS}}(
@Qualifier("{{WEBCLIENT_BEAN}}") WebClient webClient) {
super(webClient, "{{EXTERNAL_PATH}}", "{{DISPLAY_NAME}}",
new ParameterizedTypeReference<>() {},
new ParameterizedTypeReference<>() {});
}
/**
* {{DISPLAY_NAME}} 데이터를 조회합니다.
*/
public JsonNode {{METHOD_NAME}}({{METHOD_PARAMS}}) {
{{FETCH_METHOD}}
}
}
"""
.replace("{{PACKAGE}}", packageName)
.replace("{{DISPLAY_NAME}}", config.getDisplayName())
.replace("{{SERVICE_CLASS}}", serviceClass)
.replace("{{WEBCLIENT_BEAN}}", config.getWebclientBean())
.replace("{{EXTERNAL_PATH}}", config.getExternalPath())
.replace("{{METHOD_NAME}}", methodName)
.replace("{{METHOD_PARAMS}}", methodParams)
.replace("{{FETCH_METHOD}}", fetchMethod);
}
/**
* Controller 코드 생성 (RAW 모드).
* 모든 엔드포인트가 ResponseEntity<JsonNode>를 반환합니다 (외부 API 원본 JSON 그대로).
*/
private String generateControllerCode(String domain, List<BypassApiConfig> configs, Map<Long, String> responseSchemaMap, Map<Long, String> requestBodySchemaMap) {
String packageName = BASE_PACKAGE + "." + domain + ".controller";
String servicePackage = BASE_PACKAGE + "." + domain + ".service";
String domainCap = capitalize(domain);
String requestMappingPath = "/api/" + domain;
// imports (중복 제거)
Set<String> importSet = new LinkedHashSet<>();
importSet.add("import com.fasterxml.jackson.databind.JsonNode;");
importSet.add("import com.snp.batch.common.web.controller.BaseBypassController;");
importSet.add("import io.swagger.v3.oas.annotations.Operation;");
importSet.add("import io.swagger.v3.oas.annotations.Parameter;");
importSet.add("import io.swagger.v3.oas.annotations.tags.Tag;");
importSet.add("import lombok.RequiredArgsConstructor;");
importSet.add("import org.springframework.http.ResponseEntity;");
importSet.add("import org.springframework.web.bind.annotation.RequestMapping;");
importSet.add("import org.springframework.web.bind.annotation.RestController;");
boolean anyPost = configs.stream().anyMatch(c -> "POST".equalsIgnoreCase(c.getHttpMethod()));
boolean anyGet = configs.stream().anyMatch(c -> !"POST".equalsIgnoreCase(c.getHttpMethod()));
boolean anyPath = configs.stream()
.anyMatch(c -> c.getParams().stream().anyMatch(p -> "PATH".equalsIgnoreCase(p.getParamIn())));
boolean anyQuery = configs.stream()
.anyMatch(c -> c.getParams().stream().anyMatch(p -> "QUERY".equalsIgnoreCase(p.getParamIn())));
boolean anyBody = anyPost || configs.stream()
.anyMatch(c -> c.getParams().stream().anyMatch(p -> "BODY".equalsIgnoreCase(p.getParamIn())));
if (anyPost) importSet.add("import org.springframework.web.bind.annotation.PostMapping;");
if (anyGet) importSet.add("import org.springframework.web.bind.annotation.GetMapping;");
if (anyPath) importSet.add("import org.springframework.web.bind.annotation.PathVariable;");
if (anyQuery) importSet.add("import org.springframework.web.bind.annotation.RequestParam;");
if (anyBody) importSet.add("import org.springframework.web.bind.annotation.RequestBody;");
for (BypassApiConfig config : configs) {
importSet.add("import " + servicePackage + "." + config.getEndpointName() + "Service;");
}
String importsStr = importSet.stream().collect(Collectors.joining("\n"));
// 필드 선언부
StringBuilder fields = new StringBuilder();
for (BypassApiConfig config : configs) {
String serviceClass = config.getEndpointName() + "Service";
String serviceField = Character.toLowerCase(serviceClass.charAt(0)) + serviceClass.substring(1);
fields.append(" private final ").append(serviceClass).append(" ").append(serviceField).append(";\n");
}
String tagPrefix = getTagPrefix(configs.get(0).getWebclientBean());
String tagDescription = tagPrefix + " " + domainCap + " API";
// 엔드포인트 메서드 목록
StringBuilder methods = new StringBuilder();
for (BypassApiConfig config : configs) {
String endpointName = config.getEndpointName();
String serviceClass = endpointName + "Service";
String serviceField = Character.toLowerCase(serviceClass.charAt(0)) + serviceClass.substring(1);
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
// POST인데 BODY 파라미터가 없으면 자동 추가
List<BypassApiParam> ctrlParams = new ArrayList<>(config.getParams());
if (isPost && ctrlParams.stream().noneMatch(p -> "BODY".equalsIgnoreCase(p.getParamIn()))) {
BypassApiParam autoBody = new BypassApiParam();
autoBody.setParamName("body");
autoBody.setParamType("STRING");
autoBody.setParamIn("BODY");
autoBody.setRequired(false);
autoBody.setDescription("요청 본문 (JSON)");
autoBody.setExample("{}");
autoBody.setSortOrder(999);
ctrlParams.add(autoBody);
}
String mappingAnnotation = isPost ? "@PostMapping" : "@GetMapping";
String mappingPath = buildMappingPath(ctrlParams, config.getExternalPath());
String reqBodySchema = requestBodySchemaMap.get(config.getId());
String paramAnnotations = buildControllerParamAnnotations(ctrlParams, reqBodySchema);
String serviceCallArgs = buildServiceCallArgs(ctrlParams);
String methodName = "get" + endpointName + "Data";
methods.append("\n");
methods.append(" @Operation(\n");
methods.append(" summary = \"").append(config.getDisplayName()).append("\",\n");
String opDescription = (config.getDescription() != null && !config.getDescription().isEmpty())
? config.getDescription()
: config.getDisplayName() + " 데이터를 요청하고 응답을 그대로 반환합니다.";
methods.append(" description = \"").append(opDescription).append("\"\n");
methods.append(" )\n");
// @ApiResponse with schema (if available)
String schemaClass = responseSchemaMap.get(config.getId());
if (schemaClass != null && !schemaClass.isEmpty()) {
methods.append(" @io.swagger.v3.oas.annotations.responses.ApiResponse(\n");
methods.append(" responseCode = \"200\",\n");
methods.append(" description = \"").append(config.getDisplayName()).append("\",\n");
methods.append(" content = @io.swagger.v3.oas.annotations.media.Content(\n");
methods.append(" mediaType = \"application/json\",\n");
methods.append(" array = @io.swagger.v3.oas.annotations.media.ArraySchema(\n");
methods.append(" schema = @io.swagger.v3.oas.annotations.media.Schema(implementation = ").append(schemaClass).append(".class)\n");
methods.append(" )\n");
methods.append(" )\n");
methods.append(" )\n");
}
methods.append(" ").append(mappingAnnotation).append(mappingPath).append("\n");
methods.append(" public ResponseEntity<JsonNode> ").append(methodName).append("(");
if (!paramAnnotations.isEmpty()) {
methods.append(paramAnnotations);
}
methods.append(") {\n");
methods.append(" return executeRaw(() -> ").append(serviceField)
.append(".").append(methodName).append("(").append(serviceCallArgs).append("));\n");
methods.append(" }\n");
}
return "package " + packageName + ";\n\n"
+ importsStr + "\n\n"
+ "/**\n"
+ " * " + domainCap + " API\n"
+ " * S&P Maritime API에서 데이터를 실시간 조회하여 JSON을 그대로 반환\n"
+ " */\n"
+ "@RestController\n"
+ "@RequestMapping(\"" + requestMappingPath + "\")\n"
+ "@RequiredArgsConstructor\n"
+ "@Tag(name = \"" + domainCap + "\", description = \"" + tagDescription + "\")\n"
+ "public class " + domainCap + "Controller extends BaseBypassController {\n\n"
+ fields
+ methods
+ "}\n";
}
/**
* RAW fetch 메서드 호출 코드 생성
*/
private String buildFetchMethodCall(BypassApiConfig config, List<BypassApiParam> params, 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()");
if (isPost) {
BypassApiParam bodyParam = params.stream()
.filter(p -> "BODY".equalsIgnoreCase(p.getParamIn()))
.findFirst().orElse(null);
String bodyArg = bodyParam != null ? bodyParam.getParamName() : "null";
return "return fetchRawPost(" + bodyArg + ", " + uriBuilder + ");";
} else {
return "return fetchRawGet(" + uriBuilder + ");";
}
}
private String buildMethodParams(List<BypassApiParam> params) {
return params.stream()
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.map(p -> {
String type = "BODY".equalsIgnoreCase(p.getParamIn()) ? "JsonNode" : toJavaType(p.getParamType());
return type + " " + 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(", "));
}
private String buildControllerParamAnnotations(List<BypassApiParam> params, String requestBodySchemaClass) {
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 = (p.getExample() != null && !p.getExample().isEmpty())
? p.getExample()
: getDefaultExample(p.getParamType());
return switch (p.getParamIn().toUpperCase()) {
case "PATH" -> "@Parameter(description = \"" + description + "\", example = \"" + example + "\")\n"
+ " @PathVariable " + javaType + " " + paramName;
case "BODY" -> {
StringBuilder bodyAnno = new StringBuilder();
bodyAnno.append("@io.swagger.v3.oas.annotations.parameters.RequestBody(description = \"").append(description).append("\"");
if (requestBodySchemaClass != null && !requestBodySchemaClass.isEmpty()) {
bodyAnno.append(",\n content = @io.swagger.v3.oas.annotations.media.Content(\n");
bodyAnno.append(" mediaType = \"application/json\",\n");
bodyAnno.append(" schema = @io.swagger.v3.oas.annotations.media.Schema(implementation = ").append(requestBodySchemaClass).append(".class))");
}
bodyAnno.append(")\n @RequestBody JsonNode ").append(paramName);
yield bodyAnno.toString();
}
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 "));
}
private String buildMappingPath(List<BypassApiParam> params, String externalPath) {
String endpointSegment = "";
if (externalPath != null && !externalPath.isEmpty()) {
String[] segments = externalPath.split("/");
if (segments.length > 0) {
endpointSegment = "/" + segments[segments.length - 1];
}
}
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 + "\")";
}
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;
}
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]";
};
}
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);
}
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";
};
}
}