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 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 servicePaths = new ArrayList<>(); // Generate response/request DTOs from swagger.json Map responseSchemaMap = new HashMap<>(); Map 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를 상속하여 fetchRawGet/fetchRawPost로 JsonNode를 반환합니다. */ private String generateServiceCode(String domain, String endpointName, BypassApiConfig config, List params) { String packageName = BASE_PACKAGE + "." + domain + ".service"; String serviceClass = endpointName + "Service"; boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod()); // POST인데 BODY 파라미터가 없으면 자동 추가 List 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 { 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를 반환합니다 (외부 API 원본 JSON 그대로). */ private String generateControllerCode(String domain, List configs, Map responseSchemaMap, Map requestBodySchemaMap) { String packageName = BASE_PACKAGE + "." + domain + ".controller"; String servicePackage = BASE_PACKAGE + "." + domain + ".service"; String domainCap = capitalize(domain); String requestMappingPath = "/api/" + domain; // imports (중복 제거) Set 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 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 ").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 params, boolean isPost) { List 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 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 params) { return params.stream() .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .map(BypassApiParam::getParamName) .collect(Collectors.joining(", ")); } private String buildControllerParamAnnotations(List 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 params, String externalPath) { String endpointSegment = ""; if (externalPath != null && !externalPath.isEmpty()) { String[] segments = externalPath.split("/"); if (segments.length > 0) { endpointSegment = "/" + segments[segments.length - 1]; } } List 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"; }; } }