Merge remote-tracking branch 'origin/main'
Some checks failed
Deploy to OCIWP / deploy (push) Has been cancelled

This commit is contained in:
Boram
2026-08-11 16:23:03 +09:00
130 changed files with 2463 additions and 586 deletions

View File

@@ -0,0 +1,14 @@
package io.shinhanlife.dap.lib.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a response DTO whose generated JSON Schema must be exposed and validated for a Tool response.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface McpOutputSchema {
}

View File

@@ -23,7 +23,7 @@ import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
@@ -36,7 +36,7 @@ public class ToolSlaMonitoringAspect {
private final McpProperties mcpProperties;
// @McpTool 어노테이션이 붙은 모든 비즈니스 툴 메서드 실행을 가로챕니다.
@Around("@annotation(org.springframework.ai.mcp.annotation.McpTool)")
@Around("@annotation(org.springaicommunity.mcp.annotation.McpTool)")
public Object monitorToolSla(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.lib.config;
import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* Registers the temporary Glow HTTP compatibility component from the DAP library scan scope.
* The bean is only created when an official GlowHttpComponent has not already been supplied.
*/
@Configuration(proxyBeanMethods = false)
public class AxhubHttpConfiguration {
@Bean
@ConditionalOnMissingBean(GlowHttpComponent.class)
public GlowHttpComponent glowHttpComponent(RestClient.Builder restClientBuilder) {
return new GlowHttpComponent(restClientBuilder);
}
}

View File

@@ -2,6 +2,7 @@ package io.shinhanlife.dap.lib.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -15,4 +16,9 @@ public class ToolSchemaConfiguration {
public ToolSchemaResolver toolSchemaResolver(ObjectMapper objectMapper) {
return new ToolSchemaResolver(objectMapper);
}
@Bean
public ToolArgumentSchemaValidator toolArgumentSchemaValidator(ObjectMapper objectMapper) {
return new ToolArgumentSchemaValidator(objectMapper);
}
}

View File

@@ -0,0 +1,124 @@
package io.shinhanlife.dap.lib.integration.http.component;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaderContext;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaders;
import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import io.shinhanlife.glow.communication.module.http.dto.HttpBody;
import io.shinhanlife.glow.communication.module.http.dto.HttpHeader;
import io.shinhanlife.glow.communication.module.http.dto.HttpTransfer;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
/**
* Tool Pod outbound HTTP component modelled after the ShinhanLife HTTP component.
* It resolves an API domain from configuration and delegates the assembled HttpTransfer to Glow.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AxhubHttpComponent {
private static final String ANONYMOUS_REQUEST = "AXHUB-TOOL";
private final GlowHttpComponent http;
private final ObjectMapper json;
private final GlowCommunicationProperties communicationProperties;
private final AxhubHttpProperties properties;
public <T, R> R call(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass) {
return call(domain, uri, inputDto, responseBodyClass, 0);
}
public <T, R> R call(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass, int timeout) {
if (uri == null || uri.isBlank()) {
throw new IllegalArgumentException("URI is required.");
}
AxhubHttpProperties.ApiDefinition api = resolveApi(domain);
HttpHeader header = createHeader(api, timeout);
String requestUri = joinPath(api.path(), uri);
HttpTransfer<T> request = HttpTransfer.<T>http()
.header(header)
.domain(api.domain())
.uri(requestUri)
.method(api.method())
.contentType(MediaType.APPLICATION_JSON)
.responseEntity(responseBodyClass)
.body(inputDto)
.build();
log.info("[AxhubHttpComponent] Glow HTTP call. domain={}, method={}, uri={}",
domain.getCode(), api.method(), requestUri);
ResponseEntity<HttpBody> response = http.sync(request);
return convertResponse(response.getBody(), responseBodyClass);
}
/** Convenience method for APIs configured as internal business Pods. */
public <T, R> R callBizPod(AxhubHttpDomain domain, String uri, T inputDto, Class<R> responseBodyClass) {
AxhubHttpProperties.ApiDefinition api = resolveApi(domain);
if (!api.bizPod()) {
throw new IllegalArgumentException("Configured API is not a business Pod: " + domain.getCode());
}
return call(domain, uri, inputDto, responseBodyClass);
}
private AxhubHttpProperties.ApiDefinition resolveApi(AxhubHttpDomain domain) {
return properties.getApiList().stream()
.filter(api -> domain.getCode().equals(api.name()))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("No HTTP API configuration for domain: " + domain.getCode()));
}
private HttpHeader createHeader(AxhubHttpProperties.ApiDefinition api, int timeout) {
HttpHeader header = new HttpHeader();
if (api.bizPod()) {
header.set("X-POD-TO-POD", "true");
}
McpRequestHeaders inbound = McpRequestHeaderContext.current();
if (inbound == null) {
header.set("X-ANONYMOUS-REQ", ANONYMOUS_REQUEST);
} else {
putIfPresent(header, "trace-id", inbound.traceId());
putIfPresent(header, "request-id", inbound.requestId());
putIfPresent(header, "X-USER-ID", inbound.encryptedEmployeeId());
}
header.setReadTimeout(timeout == 0 ? defaultReadTimeout() : timeout);
return header;
}
private int defaultReadTimeout() {
return communicationProperties == null || communicationProperties.getHttp() == null
? 0 : communicationProperties.getHttp().getReadTimeout();
}
private <R> R convertResponse(HttpBody responseBody, Class<R> responseBodyClass) {
String content = responseBody == null ? null : responseBody.content();
if (responseBodyClass == String.class) {
return responseBodyClass.cast(content);
}
try {
return json.readValue(content, responseBodyClass);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Failed to convert HTTP response to " + responseBodyClass.getSimpleName(), e);
}
}
private void putIfPresent(HttpHeader header, String name, String value) {
if (value != null && !value.isBlank()) {
header.set(name, value);
}
}
private String joinPath(String basePath, String uri) {
String left = basePath == null ? "" : basePath.replaceAll("/+$", "");
String right = uri.startsWith("/") ? uri : "/" + uri;
return left + right;
}
}

View File

@@ -0,0 +1,16 @@
package io.shinhanlife.dap.lib.integration.http.component;
/** Registered outbound HTTP API domains. Add a domain only after its endpoint is configured. */
public enum AxhubHttpDomain {
SAMPLE("sample");
private final String code;
AxhubHttpDomain(String code) {
this.code = code;
}
public String getCode() {
return code;
}
}

View File

@@ -0,0 +1,22 @@
package io.shinhanlife.dap.lib.integration.http.component;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
/** Domain-to-endpoint configuration for outbound Tool HTTP calls. */
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "glow.communication.http")
public class AxhubHttpProperties {
private List<ApiDefinition> apiList = new ArrayList<>();
public record ApiDefinition(String name, String domain, String path, HttpMethod method, boolean bizPod) {
}
}

View File

@@ -0,0 +1,101 @@
package io.shinhanlife.dap.lib.mcp;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/** Executes a cached Tool independently from its HTTP or MCP transport. */
@Slf4j
@Service
@RequiredArgsConstructor
public class McpToolExecutionService {
private final McpToolMethodRegistry toolMethodRegistry;
private final ObjectMapper objectMapper;
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
private final ToolSchemaResolver toolSchemaResolver;
public ToolExecutionResult execute(String functionName, McpRequestHeaders requestHeaders,
Map<String, Object> arguments) {
String headerRequestId = requestHeaders == null ? null : requestHeaders.headerRequestId();
String traceId = requestHeaders == null ? null : requestHeaders.traceId();
String requestId = requestHeaders == null ? null : requestHeaders.requestId();
log.info("[Tool] IN - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName);
McpToolMethodRegistry.RegisteredTool resolvedTool = toolMethodRegistry.find(functionName);
if (resolvedTool == null) {
return error(404, "TOOL_NOT_FOUND", "Tool not found: " + functionName, headerRequestId);
}
ToolExecutionResult validationFailure = validateInput(resolvedTool, arguments, headerRequestId);
if (validationFailure != null) {
return validationFailure;
}
try {
Object methodResult = invoke(resolvedTool, convertArgument(resolvedTool.method(), arguments));
ToolExecutionResult outputFailure = validateOutput(resolvedTool, methodResult, headerRequestId);
if (outputFailure != null) {
return outputFailure;
}
Map<String, String> headers = new HashMap<>();
if (traceId != null) headers.put("trace-id", traceId);
if (requestId != null) headers.put("request-id", requestId);
log.info("[Tool] OUT - trace-id: {}, request-id: {}, tool: {}", traceId, requestId, functionName);
return new ToolExecutionResult(200, methodResult, headers);
} catch (Exception error) {
log.error("[Tool] Tool execution failed. tool={}", functionName, error);
return error(502, "TOOL_ERROR", "Tool execution failed", headerRequestId);
}
}
private ToolExecutionResult validateInput(McpToolMethodRegistry.RegisteredTool tool,
Map<String, Object> arguments, String requestId) {
if (tool.method().getParameterCount() == 0 || Map.class.isAssignableFrom(tool.method().getParameterTypes()[0])) return null;
try {
Map<String, Object> schema = toolSchemaResolver.resolve(tool.annotation(), tool.hint(), tool.method().getParameterTypes()[0]);
List<Error> errors = toolArgumentSchemaValidator.validate(schema, arguments);
return errors.isEmpty() ? null : error(422, "INVALID_PARAM", "Tool arguments do not match the input schema", requestId);
} catch (Exception error) {
log.error("[Tool] Input schema validation failed unexpectedly. tool={}", tool.annotation().name(), error);
return null;
}
}
private Object convertArgument(Method method, Map<String, Object> arguments) {
if (method.getParameterCount() == 0 || arguments == null || Map.class.isAssignableFrom(method.getParameterTypes()[0])) return arguments;
return objectMapper.convertValue(arguments, method.getParameterTypes()[0]);
}
private Object invoke(McpToolMethodRegistry.RegisteredTool tool, Object argument) throws Exception {
return tool.method().getParameterCount() == 0 ? tool.method().invoke(tool.bean()) : tool.method().invoke(tool.bean(), argument);
}
private ToolExecutionResult validateOutput(McpToolMethodRegistry.RegisteredTool tool,
Object methodResult, String requestId) {
try {
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(tool.annotation(), tool.method().getReturnType(), tool.hint());
if (!outputSchema.isEmpty() && !toolArgumentSchemaValidator.validateValue(outputSchema, methodResult).isEmpty()) {
return error(500, "INVALID_TOOL_RESPONSE", "Tool response does not match its output schema", requestId);
}
} catch (Exception error) {
log.error("[Tool] Output schema validation failed unexpectedly. tool={}", tool.annotation().name(), error);
}
return null;
}
private ToolExecutionResult error(int statusCode, String code, String message, String requestId) {
Map<String, Object> body = new HashMap<>();
body.put("code", code);
body.put("message", message);
body.put("details", Map.of("status", Integer.toString(statusCode)));
if (requestId != null) body.put("request_id", requestId);
return new ToolExecutionResult(statusCode, body, Map.of());
}
}

View File

@@ -0,0 +1,87 @@
package io.shinhanlife.dap.lib.mcp;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springaicommunity.mcp.annotation.McpTool;
import org.springframework.aop.support.AopUtils;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Caches executable {@link McpTool} methods once when a Tool Pod starts.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class McpToolMethodRegistry {
private final ApplicationContext applicationContext;
private final McpProperties mcpProperties;
private volatile Map<String, RegisteredTool> tools = Map.of();
@EventListener(ApplicationReadyEvent.class)
public void initialize() {
Map<String, RegisteredTool> discovered = new LinkedHashMap<>();
for (Object bean : applicationContext.getBeansOfType(Object.class).values()) {
Class<?> targetClass = AopUtils.getTargetClass(bean);
for (Method declaredMethod : targetClass.getDeclaredMethods()) {
McpTool annotation = AnnotationUtils.findAnnotation(declaredMethod, McpTool.class);
if (annotation == null) {
continue;
}
RegisteredTool tool = new RegisteredTool(
bean,
findInvocableMethod(bean, declaredMethod),
annotation,
AnnotationUtils.findAnnotation(declaredMethod, ToolHint.class));
register(discovered, annotation.name(), tool);
registerNamespaceAlias(discovered, annotation.name(), tool);
}
}
tools = Map.copyOf(discovered);
log.info("[Tool Registry] {} executable tool names cached", tools.size());
}
public RegisteredTool find(String toolName) {
return tools.get(toolName);
}
private void registerNamespaceAlias(Map<String, RegisteredTool> discovered, String toolName,
RegisteredTool tool) {
String namespace = mcpProperties.getNamespace();
if (StringUtils.hasText(namespace)) {
register(discovered, namespace + "_" + toolName, tool);
}
}
private void register(Map<String, RegisteredTool> discovered, String toolName, RegisteredTool tool) {
RegisteredTool existing = discovered.putIfAbsent(toolName, tool);
if (existing != null && existing != tool) {
throw new IllegalStateException("Duplicate @McpTool name: " + toolName);
}
}
private Method findInvocableMethod(Object bean, Method declaredMethod) {
try {
return bean.getClass().getMethod(declaredMethod.getName(), declaredMethod.getParameterTypes());
} catch (NoSuchMethodException ignored) {
return declaredMethod;
}
}
public record RegisteredTool(Object bean, Method method, McpTool annotation, ToolHint hint) {
}
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.dap.lib.mcp;
import java.util.Map;
/** Protocol-neutral result of invoking one Tool Pod business tool. */
public record ToolExecutionResult(int statusCode, Object body, Map<String, String> headers) {
public ToolExecutionResult {
headers = headers == null ? Map.of() : Map.copyOf(headers);
}
public boolean isSuccess() {
return statusCode >= 200 && statusCode < 300;
}
}

View File

@@ -5,33 +5,29 @@ import io.modelcontextprotocol.server.McpServerFeatures;
import io.modelcontextprotocol.server.McpSyncServer;
import io.modelcontextprotocol.spec.McpSchema;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
import io.shinhanlife.dap.lib.mcp.ToolRegistryHeartbeatSender;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaderContext;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaders;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
/** Registers the Tool Pod's existing annotated tools with its MCP SDK server. */
@Component
@ConditionalOnBean(BusinessToolController.class)
@ConditionalOnBean(McpToolExecutionService.class)
public class ToolPodMcpToolSynchronizer {
private final McpSyncServer mcpServer;
private final ToolRegistryHeartbeatSender heartbeatSender;
private final BusinessToolController businessToolController;
private final McpToolExecutionService toolExecutionService;
private final ObjectMapper objectMapper;
public ToolPodMcpToolSynchronizer(McpSyncServer mcpServer, ToolRegistryHeartbeatSender heartbeatSender,
BusinessToolController businessToolController, ObjectMapper objectMapper) {
McpToolExecutionService toolExecutionService, ObjectMapper objectMapper) {
this.mcpServer = mcpServer;
this.heartbeatSender = heartbeatSender;
this.businessToolController = businessToolController;
this.toolExecutionService = toolExecutionService;
this.objectMapper = objectMapper;
}
@@ -46,12 +42,14 @@ public class ToolPodMcpToolSynchronizer {
McpSchema.Tool mcpTool = McpSchema.Tool.builder()
.name(tool.getName())
.description(tool.getDescription() == null || tool.getDescription().isBlank() ? tool.getName() + " Tool" : tool.getDescription())
.inputSchema(tool.getParametersSchema() == null ? emptySchema() : tool.getParametersSchema())
.annotations(McpSchema.ToolAnnotations.builder()
.readOnlyHint(Boolean.TRUE.equals(tool.getReadOnlyHint()))
.destructiveHint(Boolean.TRUE.equals(tool.getDestructiveHint()))
.idempotentHint(Boolean.TRUE.equals(tool.getIdempotentHint()))
.openWorldHint(Boolean.TRUE.equals(tool.getOpenWorldHint())).build())
.inputSchema(toJsonSchema(tool.getParametersSchema()))
.annotations(new McpSchema.ToolAnnotations(
tool.getDisplayName(),
tool.getReadOnlyHint(),
tool.getDestructiveHint(),
tool.getIdempotentHint(),
tool.getOpenWorldHint(),
null))
.build();
return McpServerFeatures.SyncToolSpecification.builder().tool(mcpTool)
.callHandler((context, request) -> invoke(tool.getName(), McpRequestHeaderContext.current(), request.arguments())).build();
@@ -59,15 +57,9 @@ public class ToolPodMcpToolSynchronizer {
private McpSchema.CallToolResult invoke(String toolName, McpRequestHeaders requestHeaders,
Map<String, Object> arguments) {
ResponseEntity<?> response = businessToolController.executeDynamicTool(
toolName,
requestHeaders == null ? null : requestHeaders.headerRequestId(),
requestHeaders == null ? null : requestHeaders.traceId(),
requestHeaders == null ? null : requestHeaders.requestId(),
requestHeaders == null ? null : requestHeaders.encryptedEmployeeId(),
arguments);
boolean failed = !response.getStatusCode().is2xxSuccessful();
Object body = response.getBody();
ToolExecutionResult result = toolExecutionService.execute(toolName, requestHeaders, arguments);
boolean failed = !result.isSuccess();
Object body = result.body();
try {
return McpSchema.CallToolResult.builder().addTextContent(objectMapper.writeValueAsString(body))
.structuredContent(body).isError(failed).build();
@@ -83,4 +75,19 @@ public class ToolPodMcpToolSynchronizer {
schema.put("additionalProperties", false);
return schema;
}
@SuppressWarnings("unchecked")
private McpSchema.JsonSchema toJsonSchema(Map<String, Object> source) {
Map<String, Object> schema = source == null ? emptySchema() : source;
return new McpSchema.JsonSchema(
String.valueOf(schema.getOrDefault("type", "object")),
schema.get("properties") instanceof Map<?, ?> properties
? (Map<String, Object>) properties : Map.of(),
schema.get("required") instanceof List<?> required
? (List<String>) required : List.of(),
schema.get("additionalProperties") instanceof Boolean additionalProperties
? additionalProperties : Boolean.TRUE,
schema.get("$defs") instanceof Map<?, ?> defs ? (Map<String, Object>) defs : Map.of(),
schema.get("definitions") instanceof Map<?, ?> definitions
? (Map<String, Object>) definitions : Map.of());
}
}

View File

@@ -16,7 +16,7 @@ package io.shinhanlife.dap.lib.mcp;
* </pre>
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
@@ -44,14 +44,13 @@ import org.springframework.util.ClassUtils;
import org.springframework.web.client.RestClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import io.shinhanlife.dap.mcc.presentation.BusinessToolController;
@Slf4j
@Component
@Configuration
@EnableScheduling
@RequiredArgsConstructor
@ConditionalOnBean(BusinessToolController.class)
@ConditionalOnBean(McpToolExecutionService.class)
public class ToolRegistryHeartbeatSender {
private final ApplicationContext applicationContext;
@@ -92,10 +91,13 @@ public class ToolRegistryHeartbeatSender {
String subToolName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + rawSubToolName
: rawSubToolName;
boolean isRegister = hintAnnotation != null && hintAnnotation.register();
// @ToolHint(register = false)인 Tool은 메타데이터 조회에는 남기되,
// Gateway 등록 및 heartbeat 전송 대상에서는 제외합니다.
// ToolHint가 없는 기존 Tool은 이전 동작과 동일하게 등록합니다.
boolean isRegister = hintAnnotation == null || hintAnnotation.register();
if (!isRegister) {
log.info(" [HeartbeatSender] '{}' 툴은 어노테이션 설정에 의해 외부 등록(Redis) 대상에서 제외되었습니다. (최종 이름: {})", baseName, subToolName);
log.info(" [HeartbeatSender] '{}' Tool is excluded from Gateway registration because register=false. (tool name: {})",
baseName, subToolName);
}
ToolMetadata meta = new ToolMetadata();

View File

@@ -2,7 +2,7 @@ package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import org.springframework.ai.mcp.annotation.McpToolParam;
import org.springaicommunity.mcp.annotation.McpToolParam;
import io.swagger.v3.oas.annotations.media.Schema;
import java.lang.reflect.Field;
import java.lang.reflect.ParameterizedType;
@@ -76,7 +76,8 @@ public class JsonSchemaGenerator {
if (!schemaAnnotation.description().isEmpty() && !fieldSchema.containsKey("description")) {
fieldSchema.put("description", schemaAnnotation.description());
}
if (schemaAnnotation.required() && !requiredList.contains(field.getName())) {
if ((schemaAnnotation.required() || schemaAnnotation.requiredMode() == Schema.RequiredMode.REQUIRED)
&& !requiredList.contains(field.getName())) {
requiredList.add(field.getName());
}
if (!schemaAnnotation.pattern().isEmpty()) {

View File

@@ -46,7 +46,11 @@ public class PodScaffolder {
public static String scaffoldPod(String moduleName, String portStr, String shortName, String author, String createDate) throws IOException {
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
return scaffoldPod(rootDir, moduleName, portStr, shortName, author, createDate);
}
static String scaffoldPod(Path rootDir, String moduleName, String portStr, String shortName,
String author, String createDate) throws IOException {
Path modulePath = rootDir.resolve(Paths.get(moduleName));
if (Files.exists(modulePath)) {
return "[오류] 이미 존재하는 모듈입니다: " + moduleName;
@@ -59,25 +63,27 @@ public class PodScaffolder {
log.append("[2/6] build.gradle 생성 중...\n");
String buildGradle = """
plugins {
// Spring Boot 3.5.11 version is managed by the root build.gradle.
id 'org.springframework.boot'
}
dependencies {
// Shared MCP, Glow integration, validation, logging, Lombok and MapStruct configuration.
implementation project(':dap-was-lib')
}
dependencies {
compileOnly 'org.projectlombok:lombok:1.18.32'
annotationProcessor 'org.projectlombok:lombok:1.18.32'
}
""";
Files.writeString(modulePath.resolve("build.gradle"), buildGradle);
log.append("[3/6] Dockerfile 생성 중...\n");
String dockerfile = """
FROM eclipse-temurin:21-jdk-alpine
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY build/libs/%s-0.0.1-SNAPSHOT.jar app.jar
RUN apk add --no-cache tzdata
ENV TZ=Asia/Seoul
COPY %s/build/libs/*-SNAPSHOT.jar app.jar
EXPOSE %s
ENTRYPOINT ["java", "-jar", "app.jar"]
""".formatted(moduleName);
""".formatted(moduleName, portStr);
Files.writeString(modulePath.resolve("Dockerfile"), dockerfile);
log.append("[4/6] Application 클래스 및 설정 파일 생성 중...\n");
@@ -132,15 +138,24 @@ public class PodScaffolder {
org.apache.kafka: ERROR
mcp:
namespace: ""
manifest:
bundle-id: %s
name-prefix: ""
security:
tenant-domains:
TESTER-DEV: ALL
""".formatted(portStr, moduleName);
""".formatted(portStr, moduleName, moduleName.replace("dap-", ""));
Files.writeString(resPath.resolve("application.yml"), applicationYml);
String applicationLocalYml = """
# Local 환경 전용 설정 (H2 메모리 DB 등)
spring:
config:
activate:
on-profile: local
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-local.yml
datasource:
url: jdbc:p6spy:h2:mem:testdb;DB_CLOSE_DELAY=-1;
driverClassName: com.p6spy.engine.spy.P6SpyDriver
@@ -186,6 +201,14 @@ public class PodScaffolder {
server:
port: ${PORT:%s}
spring:
config:
activate:
on-profile: dev
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-dev.yml
axhub:
gateway:
url: https://axhubmcp.devjun.net
@@ -223,6 +246,46 @@ public class PodScaffolder {
""".formatted(portStr, portStr);
Files.writeString(resPath.resolve("application-dev.yml"), applicationDevYml);
String applicationTestYml = """
server:
port: ${PORT:%s}
spring:
config:
activate:
on-profile: test
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-test.yml
axhub:
gateway:
url: ${AXHUB_GATEWAY_URL}
tool:
url: ${AXHUB_TOOL_URL}
""".formatted(portStr);
Files.writeString(resPath.resolve("application-test.yml"), applicationTestYml);
String applicationProdYml = """
server:
port: ${PORT:%s}
spring:
config:
activate:
on-profile: prod
import:
- classpath:glow/application-glow.yml
- classpath:glow/application-glow-prod.yml
axhub:
gateway:
url: ${AXHUB_GATEWAY_URL}
tool:
url: ${AXHUB_TOOL_URL}
""".formatted(portStr);
Files.writeString(resPath.resolve("application-prod.yml"), applicationProdYml);
String logbackXml = """
<?xml version="1.0" encoding="UTF-8"?>
<configuration>

View File

@@ -1,14 +1,16 @@
package io.shinhanlife.dap.lib.util;
import org.springframework.ai.mcp.annotation.McpToolParam;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.Scanner;
/**
@@ -42,6 +44,9 @@ public class ToolScaffolder {
private static final String BASE_PACKAGE = "io.shinhanlife.dap.mcc";
private static final String BASE_PACKAGE_PATH = "src/main/java/io/shinhanlife/dap/mcc";
public record FieldDefinition(String name, String type, String description, String example, boolean required) {
}
public static void main(String[] args) throws IOException {
Scanner scanner = new Scanner(System.in);
@@ -74,10 +79,11 @@ public class ToolScaffolder {
String useSchemaResourceStr = getOrAsk(args, 8, scanner, "9. input/output JSON Schema 파일 자동 생성 여부 (y/N): ");
boolean useSchemaResource = "y".equalsIgnoreCase(useSchemaResourceStr.trim());
String inputSchemaResource = useSchemaResource ? "classpath:mcp/schema/" + toKebabCase(baseName) + "-resource-input-schema.json" : null;
String outputSchemaResource = useSchemaResource ? "classpath:mcp/schema/" + toKebabCase(baseName) + "-resource-output-schema.json" : null;
String schemaResourceDirectory = "classpath:tool-schemas/" + group.toLowerCase() + "/";
String inputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-input-schema.json" : null;
String outputSchemaResource = useSchemaResource ? schemaResourceDirectory + toKebabCase(baseName) + "-resource-output-schema.json" : null;
String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, true, null, inputSchemaResource, outputSchemaResource);
String result = scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate, false, null, inputSchemaResource, outputSchemaResource);
System.out.println(result);
}
@@ -94,6 +100,12 @@ public class ToolScaffolder {
}
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource) throws IOException {
return scaffold(baseName, interfaceId, description, group, routingType, moduleName, author, createDate,
register, clientSystemCode, inputSchemaResource, outputSchemaResource,
List.of(new FieldDefinition("query", "String", "Search query", "example", false)), List.of());
}
public static String scaffold(String baseName, String interfaceId, String description, String group, String routingType, String moduleName, String author, String createDate, boolean register, String clientSystemCode, String inputSchemaResource, String outputSchemaResource, List<FieldDefinition> inputFields, List<FieldDefinition> outputFields) throws IOException {
baseName = toPascalCase(baseName);
String envSourceDir = System.getenv("AXHUB_SOURCE_DIR");
Path rootDir = envSourceDir != null ? Paths.get(envSourceDir) : Paths.get(".");
@@ -110,22 +122,20 @@ public class ToolScaffolder {
String schemaBaseName = toKebabCase(baseName);
String inputSchemaFileName = schemaBaseName + "-resource-input-schema.json";
String outputSchemaFileName = schemaBaseName + "-resource-output-schema.json";
Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mcp/schema"));
String inputSchemaClasspath = "classpath:mcp/schema/" + inputSchemaFileName;
String outputSchemaClasspath = "classpath:mcp/schema/" + outputSchemaFileName;
Path schemaDir = rootDir.resolve(Paths.get(moduleName, "src/main/resources/tool-schemas", group.toLowerCase()));
String inputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + inputSchemaFileName;
String outputSchemaClasspath = "classpath:tool-schemas/" + group.toLowerCase() + "/" + outputSchemaFileName;
String bizPackage = BASE_PACKAGE + ".biz." + group.toLowerCase();
boolean isMci = "MCI".equalsIgnoreCase(routingType);
String mciGroupPath = "infra/itrf/mci/" + group.toLowerCase();
String clientPkgSuffix = "";
String clientPrefixCap = "";
Path mciClientDir = null;
if (isMci && clientSystemCode != null && clientSystemCode.length() == 4) {
String clientPrefix = clientSystemCode.toLowerCase();
clientPkgSuffix = clientPrefix.substring(0, 3) + "." + clientPrefix.substring(3, 4);
clientPrefixCap = toPascalCase(clientSystemCode);
mciGroupPath = "infra/itrf/mci/" + clientPrefix.substring(0, 3) + "/" + clientPrefix.substring(3, 4);
mciGroupPath = "infra/itrf/mci/" + clientPrefix;
mciClientDir = rootDir.resolve(Paths.get(moduleName, BASE_PACKAGE_PATH, mciGroupPath));
}
@@ -178,6 +188,14 @@ public class ToolScaffolder {
private String message;
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
reqContent = reqContent
.replace("import com.fasterxml.jackson.annotation.JsonInclude;",
"import com.fasterxml.jackson.annotation.JsonInclude;\nimport io.swagger.v3.oas.annotations.media.Schema;")
.replaceAll("(?m)^\\s*@McpToolParam\\([^\\r\\n]*\\)\\R", "")
.replaceAll("(?m)^\\s*-\\(\\?:[^\\r\\n]*\\R", "")
.replace("private String phoneNumber;", "@Schema(example = \"01012345678\")\n private String phoneNumber;")
.replace("private String message;", "@Schema(example = \"테스트 메시지입니다.\")\n private String message;");
reqContent = dtoContent(bizPackage + ".dto", baseName + "Request", inputFields, author, createDate, true);
Files.writeString(dtoDir.resolve(baseName + "Request.java"), reqContent);
// Generate Response DTO
@@ -211,23 +229,24 @@ public class ToolScaffolder {
// TODO: Add response fields here. Do not include PII in the Tool response.
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
resContent = dtoContent(bizPackage + ".dto", baseName + "Response", outputFields, author, createDate, false);
Files.writeString(dtoDir.resolve(baseName + "Response.java"), resContent);
String toolName = toToolName(moduleName, group, baseName);
String toolHintLine;
if (useSchemaResource) {
toolHintLine = " @ToolHint(register = %s,\n" +
" inputSchemaResource = \"%s\",\n" +
" outputSchemaResource = \"%s\")".formatted(register, inputSchemaClasspath, outputSchemaClasspath);
toolHintLine = (" @ToolHint(register = %s, categoryKey = \"%s\", mappingId = \"%s\",\n" +
" inputSchemaResource = \"%s\",\n" +
" outputSchemaResource = \"%s\")").formatted(register, group.toLowerCase(Locale.ROOT), interfaceId, inputSchemaClasspath, outputSchemaClasspath);
} else {
toolHintLine = " @ToolHint(register = %s)".formatted(register);
toolHintLine = " @ToolHint(register = %s, categoryKey = \"%s\", mappingId = \"%s\")".formatted(register, group.toLowerCase(Locale.ROOT), interfaceId);
}
String serviceInterfaceContent = """
package %s.usecase;
import org.springframework.ai.mcp.annotation.McpTool;
import org.springaicommunity.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
@@ -279,7 +298,6 @@ public class ToolScaffolder {
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import %s.converter.%sConverter;
import %s.%s.io.%s_I;
%s
@@ -307,7 +325,7 @@ public class ToolScaffolder {
private final %sConverter converter;
@Override
public Object execute(%sRequest req) {
public %sResponse execute(%sRequest req) {
log.info("[MCI Tool] {} 요청 수신.", "%s");
try {
// MapStruct를 이용한 자동 매핑 (AI DTO -> MCI DTO)
@@ -319,10 +337,18 @@ public class ToolScaffolder {
mciReq,
Object.class
);
return resTransfer.getBody() != null ? resTransfer.getBody() : Map.of("status", "SUCCESS");
%sResponse response = new %sResponse();
response.setResultCode("SUCCESS");
response.setResultMessage(resTransfer.getBody() != null
? "MCI call completed."
: "MCI call completed without a response body.");
return response;
} catch (Exception e) {
log.error("[MCI Tool] 연동 중 오류 발생: {}", e.getMessage(), e);
return Map.of("status", "ERROR", "message", e.getMessage() != null ? e.getMessage() : "Unknown error");
%sResponse response = new %sResponse();
response.setResultCode("ERROR");
response.setResultMessage(e.getMessage() != null ? e.getMessage() : "Unknown error");
return response;
}
}
}
@@ -344,12 +370,27 @@ public class ToolScaffolder {
(clientPrefixCap.isEmpty() ? "private final AxhubMciComponent mci;" : "private final Mci" + clientPrefixCap + "Client mci;"),
baseName,
baseName,
baseName,
toolName,
interfaceId,
interfaceId
interfaceId,
baseName,
baseName,
baseName,
baseName
);
String mciIoPackage = BASE_PACKAGE + "." + mciGroupPath.replace("/", ".");
serviceImplContent = serviceImplContent
.replace("import " + mciIoPackage + ".io." + interfaceId + "_I;",
"import " + mciIoPackage + ".io." + interfaceId + "_I;\nimport " + mciIoPackage + ".io." + interfaceId + "_O;")
.replace("Transfer<Object> resTransfer", "Transfer<" + interfaceId + "_O> resTransfer")
.replace("Object.class", interfaceId + "_O.class")
.replace("response.setResultCode(\"SUCCESS\");",
"if (resTransfer.getBody() != null) {\n response = converter.toResponse(resTransfer.getBody());\n }\n response.setResultCode(\"SUCCESS\");");
} else {
serviceImplContent = """
serviceImplContent = "HTTP".equalsIgnoreCase(routingType)
? httpUseCaseImplContent(bizPackage, baseName, interfaceId, author, createDate)
: """
package %s.usecase.impl;
import %s.dto.%sRequest;
@@ -383,9 +424,16 @@ public class ToolScaffolder {
private final %sConverter converter;
@Override
public Object execute(%sRequest req) {
public %sResponse execute(%sRequest req) {
// %sLegacyRequest legacyRequest = converter.toLegacyRequest(req);
return executeLegacy("%s", "%s", req); // Or pass legacyRequest
Object legacyResponse = executeLegacy("%s", "%s", req); // Or pass legacyRequest
if (legacyResponse instanceof %sResponse response) {
return response;
}
%sResponse response = new %sResponse();
response.setResultCode("SUCCESS");
response.setResultMessage("Legacy call completed.");
return response;
}
}
""".formatted(
@@ -402,6 +450,10 @@ public class ToolScaffolder {
baseName,
baseName,
baseName,
baseName,
baseName,
baseName,
baseName,
routingType, interfaceId
);
}
@@ -441,6 +493,7 @@ public class ToolScaffolder {
private String content;
}
""".formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId);
mciReqContent = mciIoContent(mciGroupPath.replace("/", "."), interfaceId + "_I", inputFields, author, createDate);
Files.writeString(mciIoDir.resolve(interfaceId + "_I.java"), mciReqContent);
String mciResContent = """
@@ -467,6 +520,7 @@ public class ToolScaffolder {
// TODO: Add response fields here
}
""".formatted(BASE_PACKAGE, mciGroupPath.replace("/", "."), BASE_PACKAGE, mciGroupPath.replace("/", "."), interfaceId, author, createDate, createDate, author, interfaceId);
mciResContent = mciIoContent(mciGroupPath.replace("/", "."), interfaceId + "_O", outputFields, author, createDate);
Files.writeString(mciIoDir.resolve(interfaceId + "_O.java"), mciResContent);
String converterContent = """
@@ -518,6 +572,7 @@ public class ToolScaffolder {
baseName, interfaceId,
baseName, interfaceId
);
converterContent = mciConverterContent(bizPackage, baseName, mciGroupPath.replace("/", "."), interfaceId);
Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent);
log.append("\n=========================================\n");
@@ -559,7 +614,7 @@ public class ToolScaffolder {
public class Mci%sClient {
private final AxhubMciComponent mci;
public Transfer<Object> callTo(String interfaceId, String dummy, Object mciReq, Class<Object> resType) throws Exception {
public <O> Transfer<O> callTo(String interfaceId, String dummy, Object mciReq, Class<O> resType) throws Exception {
return mci.callTo(interfaceId, dummy, mciReq, resType);
}
}
@@ -604,6 +659,7 @@ public class ToolScaffolder {
private String content;
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
legacyReqContent = dtoContent(bizPackage + ".legacy", baseName + "LegacyRequest", inputFields, author, createDate, true);
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyRequest.java"), legacyReqContent);
String legacyResContent = """
@@ -630,6 +686,7 @@ public class ToolScaffolder {
// TODO: Add legacy response fields here
}
""".formatted(bizPackage, bizPackage, baseName, author, createDate, createDate, author, baseName);
legacyResContent = dtoContent(bizPackage + ".legacy", baseName + "LegacyResponse", outputFields, author, createDate, true);
Files.writeString(legacyDtoDir.resolve(baseName + "LegacyResponse.java"), legacyResContent);
String converterContent = """
@@ -679,6 +736,7 @@ public class ToolScaffolder {
bizPackage, baseName, author, createDate, createDate, author,
baseName, baseName, baseName, baseName, baseName, baseName, baseName
);
converterContent = legacyConverterContent(bizPackage, baseName);
Files.writeString(converterDir.resolve(baseName + "Converter.java"), converterContent);
log.append("\n=========================================\n");
@@ -732,6 +790,17 @@ public class ToolScaffolder {
log.append("[Output Schema] ").append(schemaDir.resolve(outputSchemaFileName)).append("\n");
}
Path mockResponsePath = rootDir.resolve(Paths.get(moduleName, "src/main/resources/mock-responses", toolName + ".json"));
Files.createDirectories(mockResponsePath.getParent());
Files.writeString(mockResponsePath, mockResponseContent(outputFields));
Path generatedTestDir = rootDir.resolve(Paths.get(moduleName, "src/test/java/io/shinhanlife/dap/mcc/biz", group.toLowerCase(), "usecase"));
Files.createDirectories(generatedTestDir);
Path generatedTestPath = generatedTestDir.resolve(baseName + "UseCaseTest.java");
Files.writeString(generatedTestPath, useCaseTestContent(bizPackage, baseName));
log.append("[Mock Response] ").append(mockResponsePath).append("\\n");
log.append("[Unit Test] ").append(generatedTestPath).append("\\n");
log.append("[Test Command] .\\gradlew.bat :").append(moduleName.substring(moduleName.lastIndexOf(java.io.File.separator) + 1)).append(":test --tests \"*").append(baseName).append("UseCaseTest\"\\n");
log.append("\n Tip: ").append(interfaceId).append(" 목업 데이터를 mock-responses.json에 추가하세요.\n");
return log.toString();
@@ -744,6 +813,223 @@ public class ToolScaffolder {
.toLowerCase(Locale.ROOT);
}
private static String dtoContent(String packageName, String className, List<FieldDefinition> fields,
String author, String createDate, boolean request) {
String body = fieldLines(fields, request ? Set.of() : Set.of("resultCode", "resultMessage"));
if (!request) {
body = " private String resultCode;\n\n private String resultMessage;\n" + body;
}
return """
package %s;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class %s {
%s}
""".formatted(packageName, className, body);
}
private static String mciIoContent(String packageSuffix, String className, List<FieldDefinition> fields,
String author, String createDate) {
return """
package %s.%s.io;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class %s {
%s}
""".formatted(BASE_PACKAGE, packageSuffix, className, fieldLines(fields));
}
private static String httpUseCaseImplContent(String bizPackage, String baseName, String interfaceId, String author, String createDate) {
return """
package %s.usecase.impl;
import %s.converter.%sConverter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.legacy.%sLegacyRequest;
import %s.legacy.%sLegacyResponse;
import %s.usecase.%sUseCase;
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpComponent;
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpDomain;
import io.shinhanlife.dap.mcc.usecase.AbstractMcpToolUseCase;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* HTTP Tool implementation. Calls the Glow HTTP adapter through AxhubHttpComponent.
* Configure the target domain in AxhubHttpDomain and glow.communication.http.api-list before use.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class %sUseCaseImpl extends AbstractMcpToolUseCase implements %sUseCase {
private final %sConverter converter;
private final AxhubHttpComponent http;
@Override
public %sResponse execute(%sRequest req) {
%sLegacyRequest legacyRequest = converter.toLegacyRequest(req);
%sLegacyResponse legacyResponse = http.call(
AxhubHttpDomain.SAMPLE,
"/%s",
legacyRequest,
%sLegacyResponse.class
);
%sResponse response = converter.toResponse(legacyResponse);
response.setResultCode("SUCCESS");
response.setResultMessage("HTTP API call completed.");
return response;
}
}
""".formatted(
bizPackage, bizPackage, baseName, bizPackage, baseName, bizPackage, baseName,
bizPackage, baseName, bizPackage, baseName, bizPackage, baseName,
baseName, baseName, baseName, baseName, baseName, baseName, baseName,
interfaceId, baseName, baseName);
}
private static String mciConverterContent(String bizPackage, String baseName, String mciPackage, String interfaceId) {
return """
package %s.converter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.%s.io.%s_I;
import %s.%s.io.%s_O;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring")
public interface %sConverter {
%s_I toLegacyRequest(%sRequest request);
%sRequest toRequest(%s_I mciRequest);
%sResponse toResponse(%s_O mciRes);
}
""".formatted(bizPackage, bizPackage, baseName, bizPackage, baseName,
BASE_PACKAGE, mciPackage, interfaceId, BASE_PACKAGE, mciPackage, interfaceId,
baseName, interfaceId, baseName, baseName, interfaceId, baseName, interfaceId);
}
private static String legacyConverterContent(String bizPackage, String baseName) {
return """
package %s.converter;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import %s.legacy.%sLegacyRequest;
import %s.legacy.%sLegacyResponse;
import org.mapstruct.Mapper;
import org.mapstruct.ReportingPolicy;
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface %sConverter {
%sLegacyRequest toLegacyRequest(%sRequest request);
%sRequest toRequest(%sLegacyRequest legacyRequest);
%sResponse toResponse(%sLegacyResponse legacyResponse);
}
""".formatted(
bizPackage, bizPackage, baseName, bizPackage, baseName,
bizPackage, baseName, bizPackage, baseName,
baseName, baseName, baseName, baseName, baseName, baseName, baseName);
}
private static String fieldLines(List<FieldDefinition> fields) {
return fieldLines(fields, Set.of());
}
private static String fieldLines(List<FieldDefinition> fields, Set<String> excludedNames) {
StringBuilder source = new StringBuilder();
Set<String> generatedNames = new LinkedHashSet<>();
for (FieldDefinition field : fields == null ? List.<FieldDefinition>of() : fields) {
if (field == null || field.name() == null || field.name().isBlank()) {
continue;
}
String fieldName = field.name().trim();
if (excludedNames.contains(fieldName) || !generatedNames.add(fieldName)) {
continue;
}
String type = supportedType(field.type());
String description = field.description() == null ? "" : field.description().replace("\"", "\\\"");
String example = field.example() == null ? "" : field.example().replace("\"", "\\\"");
source.append(" @Schema(description = \"").append(description).append("\", example = \"")
.append(example).append("\"");
if (field.required()) {
source.append(", requiredMode = Schema.RequiredMode.REQUIRED");
}
source.append(")\n private ").append(type).append(' ').append(fieldName).append(";\n\n");
}
return source.toString();
}
private static String supportedType(String type) {
return switch (type == null ? "String" : type) {
case "String", "Integer", "Long", "Double", "Boolean", "BigDecimal" -> type;
default -> throw new IllegalArgumentException("Unsupported field type: " + type);
};
}
private static String mockResponseContent(List<FieldDefinition> outputFields) {
StringBuilder json = new StringBuilder("{\n");
List<FieldDefinition> fields = outputFields == null ? List.of() : outputFields;
boolean first = true;
for (FieldDefinition field : fields) {
if (field.name() == null || field.name().isBlank()) {
continue;
}
if (!first) {
json.append(",\n");
}
json.append(" \"").append(jsonEscape(field.name())).append("\" : ")
.append(mockValue(field));
first = false;
}
return json.append("\n}\n").toString();
}
private static String mockValue(FieldDefinition field) {
if (field.example() == null || field.example().isBlank()) {
return "null";
}
return switch (supportedType(field.type())) {
case "Integer", "Long", "Double", "BigDecimal" -> field.example();
case "Boolean" -> Boolean.parseBoolean(field.example()) ? "true" : "false";
default -> "\"" + jsonEscape(field.example()) + "\"";
};
}
private static String jsonEscape(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static String useCaseTestContent(String bizPackage, String baseName) {
return """
package %s.usecase;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import %s.dto.%sRequest;
import %s.dto.%sResponse;
import org.junit.jupiter.api.Test;
class %sUseCaseTest {
@Test
void createsToolRequestAndResponseDtos() {
assertNotNull(new %sRequest());
assertNotNull(new %sResponse());
}
}
""".formatted(bizPackage, bizPackage, baseName, bizPackage, baseName,
baseName, baseName, baseName);
}
private static String toToolName(String moduleName, String group, String baseName) {
String moduleDirectory = Path.of(moduleName).getFileName().toString();
String pod = moduleDirectory.startsWith("dap-was-")
@@ -756,7 +1042,7 @@ public class ToolScaffolder {
String[] words = normalizedName.split("\\s+");
String service = words[0];
String action = words.length == 1 ? "execute" : words[words.length - 1];
return "%s.%s.%s.%s".formatted(
return "%s_%s_%s_%s".formatted(
pod.toLowerCase(Locale.ROOT),
group.toLowerCase(Locale.ROOT),
service,
@@ -784,4 +1070,4 @@ public class ToolScaffolder {
}
return result.toString();
}
}
}

View File

@@ -2,14 +2,13 @@ package io.shinhanlife.dap.lib.util;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
// removed McpOutputSchema
import io.shinhanlife.dap.lib.annotation.McpOutputSchema;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import java.io.InputStream;
import java.util.Map;
import org.springframework.core.io.ClassPathResource;
import io.shinhanlife.dap.lib.annotation.ToolHint;
/** Resolves an MCP Tool input schema from resource, inline value, or DTO metadata. */
/** Resolves MCP Tool schemas from resources or DTO metadata. */
public class ToolSchemaResolver {
private final ObjectMapper objectMapper;
@@ -18,7 +17,8 @@ public class ToolSchemaResolver {
this.objectMapper = objectMapper;
}
public Map<String, Object> resolve(org.springframework.ai.mcp.annotation.McpTool function, ToolHint hint, Class<?> requestType) {
public Map<String, Object> resolve(org.springaicommunity.mcp.annotation.McpTool function,
ToolHint hint, Class<?> requestType) {
if (hint != null && !hint.inputSchemaResource().isBlank()) {
return loadResource(hint.inputSchemaResource());
}
@@ -26,12 +26,11 @@ public class ToolSchemaResolver {
}
/**
* Resolves an explicitly declared response schema.
* Response schemas are opt-in so existing tools keep their current response behavior.
* ToolHint.outputSchemaResource()가 있으면 classpath JSON 파일에서 로드하고,
* 없으면 responseType DTO를 분석하여 자동 생성합니다.
* Resolves a response schema only when it is explicitly declared.
* A JSON resource has precedence over a DTO marker annotation.
*/
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function, Class<?> responseType, ToolHint hint) {
public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function,
Class<?> responseType, ToolHint hint) {
if (hint != null && !hint.outputSchemaResource().isBlank()) {
return loadResource(hint.outputSchemaResource());
}
@@ -39,16 +38,16 @@ public class ToolSchemaResolver {
}
/**
* Resolves an explicitly declared response schema.
* Response schemas are opt-in so existing tools keep their current response behavior.
* Generates a response schema only for DTOs marked with {@link McpOutputSchema}.
*/
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function, Class<?> responseType) {
// Object, Map 등 구체적인 DTO가 아닌 경우 검증 스킵
public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function,
Class<?> responseType) {
if (responseType == null
|| responseType == Object.class
|| Map.class.isAssignableFrom(responseType)
|| responseType == Void.class
|| responseType == void.class) {
|| responseType == void.class
|| !responseType.isAnnotationPresent(McpOutputSchema.class)) {
return Map.of();
}
return JsonSchemaGenerator.generateSchema(responseType);
@@ -57,8 +56,8 @@ public class ToolSchemaResolver {
/**
* Retained for callers that use only explicit output schemas.
*/
public Map<String, Object> resolveOutput(org.springframework.ai.mcp.annotation.McpTool function) {
return resolveOutput(function, null);
public Map<String, Object> resolveOutput(org.springaicommunity.mcp.annotation.McpTool function) {
return Map.of();
}
private Map<String, Object> loadResource(String location) {
@@ -76,12 +75,4 @@ public class ToolSchemaResolver {
throw new IllegalStateException("Failed to load MCP schema resource: " + location, e);
}
}
private Map<String, Object> parse(String schema, String source) {
try {
return objectMapper.readValue(schema, new TypeReference<>() { });
} catch (Exception e) {
throw new IllegalStateException("Failed to parse MCP input schema from " + source, e);
}
}
}
}

View File

@@ -28,7 +28,7 @@ import java.util.regex.Pattern;
public final class McpToolNameValidator {
private static final Pattern TOOL_NAME_PATTERN = Pattern.compile("\\bname\\s*=\\s*\\\"([^\\\"]+)\\\"");
private static final Pattern TOOL_NAME_CONVENTION = Pattern.compile("^[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*\\.[a-z][a-z0-9-]*$");
private static final Pattern TOOL_NAME_CONVENTION = Pattern.compile("^[a-zA-Z0-9_-]{1,128}$");
private McpToolNameValidator() {
}
@@ -139,7 +139,7 @@ public final class McpToolNameValidator {
}
private static String buildInvalidNameMessage(List<Map.Entry<String, List<ToolDeclaration>>> invalidNames) {
StringBuilder message = new StringBuilder("Invalid MCP tool name(s): expected pod.domain.service.action using lowercase letters, digits, or hyphens.");
StringBuilder message = new StringBuilder("Invalid MCP tool name(s): expected 1-128 characters using letters, digits, underscores, or hyphens.");
for (Map.Entry<String, List<ToolDeclaration>> invalidName : invalidNames) {
message.append("\n\n").append(invalidName.getKey());
invalidName.getValue().stream()

View File

@@ -8,10 +8,8 @@ import com.networknt.schema.SchemaRegistry;
import com.networknt.schema.SpecificationVersion;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/** Validates tool arguments with the NetworkNT version selected by the MCP SDK. */
@Component
public class ToolArgumentSchemaValidator {
private final ObjectMapper objectMapper;

View File

@@ -1,42 +1,14 @@
package io.shinhanlife.dap.mcc.presentation;
/**
* @package io.shinhanlife.dap.mcc.presentation
* @className BusinessToolController
* @description AX HUB 시스템 처리 클래스
* @author 0986406
* @create 2026.09.01
* <pre>
* ---------- 개정이력 ----------
* 수정일 수정자 수정내용
* ---------- -------- ---------------------------
* 2026.09.01 0986406 최초생성
*
* </pre>
*/
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.Error;
import org.springframework.ai.mcp.annotation.McpTool;
import io.shinhanlife.dap.lib.validation.ToolArgumentSchemaValidator;
import io.shinhanlife.dap.lib.config.McpProperties;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import io.shinhanlife.dap.lib.mcp.McpRequestHeaders;
import io.shinhanlife.dap.lib.mcp.ToolExecutionResult;
import io.shinhanlife.dap.lib.mcp.McpToolExecutionService;
import io.shinhanlife.dap.lib.mcp.ToolRegistryHeartbeatSender;
import io.shinhanlife.dap.lib.util.ToolSchemaResolver;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import io.shinhanlife.dap.mcc.dto.ToolMetadata;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.ApplicationContext;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.util.ClassUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -45,26 +17,20 @@ import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
/** Legacy REST adapter for Tool Pod execution. */
@RestController
@RequestMapping("/")
@RequiredArgsConstructor
public class BusinessToolController {
private final ApplicationContext applicationContext;
private final ObjectMapper objectMapper;
private final McpProperties mcpProperties;
private final ToolRegistryHeartbeatSender toolRegistryHeartbeatSender;
private final ToolArgumentSchemaValidator toolArgumentSchemaValidator;
private final McpToolExecutionService toolExecutionService;
private final ToolSchemaResolver toolSchemaResolver;
// 내부 조회용 로컬 Tool 목록 엔드포인트
@GetMapping("/mcp/api/v1/tools/local")
public List<ToolMetadata> getLocalTools() {
return toolRegistryHeartbeatSender.getAllScannedTools();
}
// 순수 REST 기반 동적 라우팅 엔드포인트
@PostMapping("/mcp/{name}")
public ResponseEntity<?> executeDynamicTool(
@PathVariable("name") String functionName,
@@ -73,173 +39,12 @@ public class BusinessToolController {
@RequestHeader(value = "request-id", required = false) String requestId,
@RequestHeader(value = "employee-id", required = false) String encryptedEmployeeId,
@RequestBody(required = false) Map<String, Object> arguments) {
String finalRequestId = headerRequestId;
log.info(" [Tool] IN - trace-id: {}, request-id: {}", traceId, requestId);
log.info(" [Tool] 동적 툴 실행 요청 수신 (함수명): {}", functionName);
if (arguments != null) {
try {
log.info(" [Tool] 호출 파라미터: {}", objectMapper.writeValueAsString(arguments));
} catch (Exception e) {
log.info(" [Tool] 호출 파라미터: {}", arguments);
}
}
Object targetBean = null;
Method targetMethod = null;
McpTool targetFunctionAnnotation = null;
// McpTool 어노테이션 기반 조회가 프록시 문제로 누락될 수 있으므로, 전체 빈을 순회하며 @McpTool을 찾습니다.
Map<String, Object> allBeans = applicationContext.getBeansOfType(Object.class);
outerLoop:
for (Object bean : allBeans.values()) {
Class<?> targetClass = AopUtils.getTargetClass(bean);
for (Method targetMethodOfClass : targetClass.getDeclaredMethods()) {
McpTool mcpFunc = AnnotationUtils.findAnnotation(targetMethodOfClass, McpTool.class);
if (mcpFunc != null) {
String baseName = mcpFunc.name();
String expectedName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + baseName
: baseName;
if (expectedName.equals(functionName) || baseName.equals(functionName)) {
targetBean = bean;
try {
targetMethod = bean.getClass().getMethod(targetMethodOfClass.getName(), targetMethodOfClass.getParameterTypes());
} catch (NoSuchMethodException e) {
targetMethod = targetMethodOfClass;
}
targetFunctionAnnotation = mcpFunc;
break outerLoop;
}
}
}
}
if (targetBean == null || targetMethod == null) {
List<String> availableFunctions = new ArrayList<>();
for (Object bean : allBeans.values()) {
Class<?> targetCls = AopUtils.getTargetClass(bean);
for (Method m : targetCls.getDeclaredMethods()) {
McpTool func = AnnotationUtils.findAnnotation(m, McpTool.class);
if (func != null) {
String baseName = func.name();
String expName = mcpProperties.getNamespace() != null && !mcpProperties.getNamespace().isEmpty()
? mcpProperties.getNamespace() + "_" + baseName : baseName;
availableFunctions.add(expName + " (in " + targetCls.getSimpleName() + ")");
}
}
}
log.error("[Tool] 실행할 함수(Method)를 찾을 수 없습니다: {}. 현재 스캔된 툴 메서드 목록: {}", functionName, availableFunctions);
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "404");
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "TOOL_NOT_FOUND");
errorBody.put("message", "실행할 함수를 찾을 수 없습니다: " + functionName);
errorBody.put("details", errorDetails);
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
return ResponseEntity.status(404).body(errorBody);
}
// (기존 차단 로직 제거됨)
// 2. 파라미터 유효성 검증 (JSON Schema)
if (targetMethod.getParameterCount() > 0) {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
try {
io.shinhanlife.dap.lib.annotation.ToolHint hint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class);
Map<String, Object> inputSchema = toolSchemaResolver.resolve(targetFunctionAnnotation, hint, paramType);
List<Error> errors = toolArgumentSchemaValidator.validate(inputSchema, arguments);
if (!errors.isEmpty()) {
log.error("[Tool] 파라미터 유효성 검증 실패: {}", errors);
List<String> errorMessages = new ArrayList<>();
for (Error validationError : errors) {
errorMessages.add(validationError.getMessage());
}
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "422");
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "INVALID_PARAM");
errorBody.put("message", "파라미터 유효성 검증 실패");
errorBody.put("details", errorDetails);
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
return ResponseEntity.status(422).body(errorBody);
}
} catch (Exception e) {
log.error("[Tool] 스키마 검증 중 오류 발생: {}", e.getMessage());
}
}
}
log.info("[Tool] 리플렉션 직접 실행 -> Method: {}", targetMethod.getName());
try {
// 3. DTO 파라미터 자동 매핑 (Map -> DTO)
Object invokeArgument = arguments;
if (targetMethod.getParameterCount() > 0 && arguments != null) {
Class<?> paramType = targetMethod.getParameterTypes()[0];
if (!Map.class.isAssignableFrom(paramType)) {
invokeArgument = objectMapper.convertValue(arguments, paramType);
log.info("[Tool] DTO 자동 매핑 성공: {}", paramType.getSimpleName());
}
}
long startTime = System.currentTimeMillis();
Object methodResult = null;
if (targetMethod.getParameterCount() == 0) {
methodResult = targetMethod.invoke(targetBean);
} else {
methodResult = targetMethod.invoke(targetBean, invokeArgument);
}
io.shinhanlife.dap.lib.annotation.ToolHint outputHint = AnnotationUtils.findAnnotation(targetMethod, io.shinhanlife.dap.lib.annotation.ToolHint.class);
Map<String, Object> outputSchema = toolSchemaResolver.resolveOutput(targetFunctionAnnotation, targetMethod.getReturnType(), outputHint);
if (!outputSchema.isEmpty()) {
List<Error> outputErrors = toolArgumentSchemaValidator.validateValue(outputSchema, methodResult);
if (!outputErrors.isEmpty()) {
log.error("[Tool] Output schema validation failed. tool={}, errors={}",
functionName, outputErrors);
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "INVALID_TOOL_RESPONSE");
errorBody.put("message", "Tool response does not match its output schema");
if (finalRequestId != null) {
errorBody.put("request_id", finalRequestId);
}
return ResponseEntity.internalServerError().body(errorBody);
}
}
long elapsed = System.currentTimeMillis() - startTime;
// 5. 결과 반환 (순수 REST 응답)
try {
log.info("[Tool -> MCP Gateway] Output Schema Result: {}", objectMapper.writeValueAsString(methodResult));
} catch (Exception e) {
log.info("[Tool -> MCP Gateway] Output Schema Result: {}", methodResult);
}
log.info(" [Tool] OUT - trace-id: {}, request-id: {}", traceId, requestId);
ResponseEntity.BodyBuilder responseBuilder = ResponseEntity.ok();
if (traceId != null) responseBuilder.header("trace-id", traceId);
if (requestId != null) responseBuilder.header("request-id", requestId);
return responseBuilder.body(methodResult);
} catch (Exception e) {
log.error("[Tool] 리플렉션 실행 중 예외 발생: {}", e.getMessage());
Map<String, Object> errorDetails = new HashMap<>();
errorDetails.put("status", "500");
Map<String, Object> errorBody = new HashMap<>();
errorBody.put("code", "TOOL_ERROR");
errorBody.put("message", "Tool execution failed");
errorDetails.clear(); // Hide details for upstream errors
errorBody.put("details", errorDetails);
if (finalRequestId != null) errorBody.put("request_id", finalRequestId);
return ResponseEntity.status(502).body(errorBody);
}
ToolExecutionResult result = toolExecutionService.execute(
functionName,
new McpRequestHeaders(headerRequestId, traceId, requestId, encryptedEmployeeId),
arguments);
ResponseEntity.BodyBuilder response = ResponseEntity.status(result.statusCode());
result.headers().forEach(response::header);
return response.body(result.body());
}
}

View File

@@ -0,0 +1,6 @@
package io.shinhanlife.glow.communication;
/** Minimal Glow communication contract used by the temporary compatibility layer. */
public interface ICommunication<I, O> {
O sync(I request);
}

View File

@@ -0,0 +1,98 @@
package io.shinhanlife.glow.communication.module.http.component;
import io.shinhanlife.glow.communication.ICommunication;
import io.shinhanlife.glow.communication.module.http.dto.HttpBody;
import io.shinhanlife.glow.communication.module.http.dto.HttpHeader;
import io.shinhanlife.glow.communication.module.http.dto.HttpTransfer;
import java.util.List;
import java.util.Map;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
/**
* Temporary compatibility implementation of the internal Glow HTTP component.
* Replace this class with the official Glow HTTP JAR when it is supplied.
*/
@Component
public class GlowHttpComponent implements ICommunication<HttpTransfer<?>, ResponseEntity<HttpBody>> {
private final RestClient restClient;
public GlowHttpComponent(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder.build();
}
@Override
public ResponseEntity<HttpBody> sync(HttpTransfer<?> request) {
if (request == null || request.getHeader() == null || request.getMethod() == null
|| !StringUtils.hasText(request.getDomain())) {
throw new IllegalArgumentException("Glow HTTP request header, domain, and method are required.");
}
if (HttpMethod.GET.equals(request.getMethod())) {
return get(request);
}
if (HttpMethod.POST.equals(request.getMethod())) {
return post(request);
}
if (HttpMethod.PUT.equals(request.getMethod())) {
return put(request);
}
if (HttpMethod.DELETE.equals(request.getMethod())) {
return delete(request);
}
throw new IllegalArgumentException("Unsupported HTTP method: " + request.getMethod());
}
private ResponseEntity<HttpBody> get(HttpTransfer<?> request) {
return toHttpBody(restClient.get().uri(buildUri(request, true))
.headers(headers -> applyHeaders(headers, request.getHeader()))
.retrieve().toEntity(String.class));
}
private ResponseEntity<HttpBody> post(HttpTransfer<?> request) {
return toHttpBody(restClient.post().uri(buildUri(request, false))
.contentType(contentType(request)).headers(headers -> applyHeaders(headers, request.getHeader()))
.body(request.getBody()).retrieve().toEntity(String.class));
}
private ResponseEntity<HttpBody> put(HttpTransfer<?> request) {
return toHttpBody(restClient.put().uri(buildUri(request, false))
.contentType(contentType(request)).headers(headers -> applyHeaders(headers, request.getHeader()))
.body(request.getBody()).retrieve().toEntity(String.class));
}
private ResponseEntity<HttpBody> delete(HttpTransfer<?> request) {
return toHttpBody(restClient.delete().uri(buildUri(request, true))
.headers(headers -> applyHeaders(headers, request.getHeader()))
.retrieve().toEntity(String.class));
}
private String buildUri(HttpTransfer<?> request, boolean includeQueryParameters) {
String uri = request.getDomain() + (request.getUri() == null ? "" : request.getUri());
if (!includeQueryParameters || !(request.getBody() instanceof Map<?, ?> parameters)) {
return uri;
}
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(uri);
parameters.forEach((key, value) -> { if (key != null && value != null) builder.queryParam(String.valueOf(key), value); });
return builder.build().encode().toUriString();
}
private MediaType contentType(HttpTransfer<?> request) {
return request.getContentType() == null ? MediaType.APPLICATION_JSON : request.getContentType();
}
private void applyHeaders(HttpHeaders target, HttpHeader source) {
target.setAccept(List.of(MediaType.APPLICATION_JSON));
source.getValues().forEach((name, value) -> { if (StringUtils.hasText(name) && StringUtils.hasText(value)) target.set(name, value); });
}
private ResponseEntity<HttpBody> toHttpBody(ResponseEntity<String> response) {
return new ResponseEntity<>(new HttpBody(response.getBody()), response.getHeaders(), response.getStatusCode());
}
}

View File

@@ -0,0 +1,5 @@
package io.shinhanlife.glow.communication.module.http.dto;
/** Raw body returned by the Glow HTTP transport. */
public record HttpBody(String content) {
}

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.glow.communication.module.http.dto;
import java.util.LinkedHashMap;
import java.util.Map;
import lombok.Getter;
import lombok.Setter;
/** Glow HTTP request headers and timeout metadata. */
@Getter
@Setter
public class HttpHeader {
private Map<String, String> values = new LinkedHashMap<>();
private int readTimeout;
public void set(String name, String value) {
values.put(name, value);
}
}

View File

@@ -0,0 +1,19 @@
package io.shinhanlife.glow.communication.module.http.dto;
import lombok.Builder;
import lombok.Getter;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
/** Glow HTTP request envelope. Use HttpTransfer.http() to build a request. */
@Getter
@Builder(builderMethodName = "http")
public class HttpTransfer<T> {
private final HttpHeader header;
private final String domain;
private final String uri;
private final HttpMethod method;
private final MediaType contentType;
private final Class<?> responseEntity;
private final T body;
}

View File

@@ -1,5 +1,19 @@
# Glow 개발 환경 확장 설정
# Glow 개발 환경 설정
spring:
config:
activate:
on-profile: dev
on-profile: dev
glow:
communication:
common:
env-type: D
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST:https://dev-ichmci.shinhanlife.co.kr}
port: ${GLOW_COMMUNICATION_MCI_PORT:26160}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST:http://host.docker.internal}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT:8080}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST:tcp://host.docker.internal}
port: ${GLOW_COMMUNICATION_EAI_PORT:9999}

View File

@@ -1,5 +1,19 @@
# Glow 로컬 환경 확장 설정
# Glow 로컬 환경 설정
spring:
config:
activate:
on-profile: local
on-profile: local
glow:
communication:
common:
env-type: D
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST:http://localhost}
port: ${GLOW_COMMUNICATION_MCI_PORT:8080}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST:http://localhost}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT:8080}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST:http://localhost}
port: ${GLOW_COMMUNICATION_EAI_PORT:8080}

View File

@@ -1,5 +1,19 @@
# Glow 운영 환경 확장 설정
# Glow 운영 환경 설정
spring:
config:
activate:
on-profile: prod
on-profile: prod
glow:
communication:
common:
env-type: P
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST}
port: ${GLOW_COMMUNICATION_MCI_PORT}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST}
port: ${GLOW_COMMUNICATION_EAI_PORT}

View File

@@ -1,5 +1,19 @@
# Glow 테스트 환경 확장 설정
# Glow 테스트 환경 설정
spring:
config:
activate:
on-profile: test
on-profile: test
glow:
communication:
common:
env-type: T
mci:
host: ${GLOW_COMMUNICATION_MCI_HOST}
port: ${GLOW_COMMUNICATION_MCI_PORT}
extmci:
host: ${GLOW_COMMUNICATION_EXTMCI_HOST}
port: ${GLOW_COMMUNICATION_EXTMCI_PORT}
eai:
host: ${GLOW_COMMUNICATION_EAI_HOST}
port: ${GLOW_COMMUNICATION_EAI_PORT}

View File

@@ -1,9 +1,5 @@
# ==============================================================================
# [AXHub 공통 환경 설정 파일] (Glow Framework 연동용)
# 이 파일은 axhub-tool-core에 위치하며, 각 툴 파드들이 상속받아 사용합니다.
# ==============================================================================
# 1. 로깅(Logging) 공통 설정
# AX HUB Tool Pod 공통 Glow Framework 설정입니다.
# Tool Pod의 application-{profile}.yml에서 이 파일과 환경별 Glow 설정을 함께 import합니다.
logging:
level:
root: INFO
@@ -13,42 +9,38 @@ logging:
pattern:
console: "[%d{yyyy-MM-dd HH:mm:ss.SSS}] [%-5level] [%thread] %logger{36} - %msg%n"
# 2. Redis 공통 설정 (Gateway 연동 및 캐싱)
spring:
data:
redis:
host: 127.0.0.1 # TODO: 실제 Redis 망 IP로 변경
port: 6379 # TODO: 실제 Redis 포트로 변경
password: "" # TODO: 실제 비밀번호 입력
host: ${SPRING_DATA_REDIS_HOST:127.0.0.1}
port: ${SPRING_DATA_REDIS_PORT:6379}
password: ${SPRING_DATA_REDIS_PASSWORD:}
# 3. 신한라이프 Glow Framework 통신 (MCI / EAI) 설정
glow:
communication: #Communication 설정
common:
env-type: D # 대내표준 헤더의 환경 타입정보 (D: 개발, T: 테스트, P: 운영)
http: # HTTP 비표준 통신 설정
connection-timeout: 5 # 연결 타임아웃 시간 (초 단위)
read-timeout: 5 # 읽기 타임아웃 시간 (초 단위)
mci: # 개발계 대내 MCI 통신 설정
host: https://dev-ichmci.shinhanlife.co.kr
port: 26160
communication:
http:
connection-timeout: 5
read-timeout: 5
# HTTP Tool target catalog. Replace or add entries after the business endpoint is agreed.
api-list:
- name: sample
domain: ${AXHUB_SAMPLE_HTTP_DOMAIN:http://localhost:8099}
path: ""
method: GET
biz-pod: false
mci:
uri: /ntl_mci/dap_rcv
receive-uri: /itrf/mciReceive
connection-timeout: 300
read-timeout: 300
encoding: UTF-8
extmci: # 대외MCI 통신 설정
host: http://host.docker.internal # 대외MCI 호스트 주소
port: 8080 # 대외MCI 포트 번호
uri: /extmci # 대외MCI URI 정보
json-uri: /extmciJson # 대외MCI JSON방식 URI 정보
receive-uri: /app/extMciReceive # 대외MCI 수신 URI
connection-timeout: 5 # 연결 타임아웃 시간 (초 단위)
read-timeout: 30 # 읽기 타임아웃 시간 (초 단위)
encoding: EUC-KR # 대외MCI 인코딩 정보
eai: # EAI 통신 설정
host: tcp://host.docker.internal # EAI 호스트 주소 (TODO: 실제 주소로 변경)
port: 9999 # EAI 포트 번호
extmci:
uri: /extmci
json-uri: /extmciJson
receive-uri: /app/extMciReceive
connection-timeout: 5
read-timeout: 30
encoding: EUC-KR
websocket:
endpoint: "/ws-glow"
allowed-origins: "*"
endpoint: /ws-glow
allowed-origins: "*"