feat: add Glow HTTP tool integration sample
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 14s

This commit is contained in:
jade
2026-08-10 17:24:28 +09:00
parent 4b08021fc6
commit 40303ee9b8
15 changed files with 427 additions and 0 deletions

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 = "axhub.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,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

@@ -0,0 +1,43 @@
package io.shinhanlife.dap.lib.integration.http.component;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.http.MediaType.APPLICATION_JSON;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.shinhanlife.dap.lib.config.GlowCommunicationProperties;
import io.shinhanlife.glow.communication.module.http.component.GlowHttpComponent;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
class AxhubHttpComponentTest {
@Test
void callResolvesDomainBuildsGlowTransferAndDeserializesJsonResponse() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
GlowHttpComponent glowHttpComponent = new GlowHttpComponent(builder);
AxhubHttpProperties properties = new AxhubHttpProperties();
properties.setApiList(List.of(new AxhubHttpProperties.ApiDefinition(
"sample", "https://api.example.test", "/v1", HttpMethod.GET, false)));
AxhubHttpComponent component = new AxhubHttpComponent(
glowHttpComponent, new ObjectMapper(), new GlowCommunicationProperties(), properties);
server.expect(requestTo("https://api.example.test/v1/status"))
.andExpect(header("X-ANONYMOUS-REQ", "AXHUB-TOOL"))
.andRespond(withSuccess("{\"status\":\"OK\"}", APPLICATION_JSON));
SampleResponse response = component.call(AxhubHttpDomain.SAMPLE, "/status", null, SampleResponse.class);
assertThat(response.status()).isEqualTo("OK");
server.verify();
}
record SampleResponse(String status) {
}
}

View File

@@ -0,0 +1,9 @@
package io.shinhanlife.dap.mcc.biz.smp.dto;
import io.swagger.v3.oas.annotations.media.Schema;
/** Tool response for the HTTP integration sample. */
public record SampleHttpStatusResponse(
@Schema(description = "External API processing status", example = "OK") String status,
@Schema(description = "External API response message", example = "Sample API is available") String message) {
}

View File

@@ -0,0 +1,15 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase;
import io.shinhanlife.dap.lib.annotation.ToolHint;
import io.shinhanlife.dap.mcc.biz.smp.dto.SampleHttpStatusResponse;
import org.springaicommunity.mcp.annotation.McpTool;
/** Sample Tool that demonstrates a configured HTTP API integration. */
public interface SampleHttpStatusUseCase {
@McpTool(name = "oth.smp.sample.http.status",
title = "Sample external HTTP API status",
description = "Calls the configured sample HTTP API and returns its status.")
@ToolHint(register = false, categoryKey = "smp", mappingId = "HTTP_SAMPLE_001")
SampleHttpStatusResponse execute();
}

View File

@@ -0,0 +1,21 @@
package io.shinhanlife.dap.mcc.biz.smp.usecase.impl;
import io.shinhanlife.dap.mcc.biz.smp.dto.SampleHttpStatusResponse;
import io.shinhanlife.dap.mcc.biz.smp.usecase.SampleHttpStatusUseCase;
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleHttpApiClient;
import io.shinhanlife.dap.mcc.infra.itrf.http.sample.SampleHttpApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class SampleHttpStatusUseCaseImpl implements SampleHttpStatusUseCase {
private final SampleHttpApiClient sampleHttpApiClient;
@Override
public SampleHttpStatusResponse execute() {
SampleHttpApiResponse response = sampleHttpApiClient.getStatus();
return new SampleHttpStatusResponse(response.status(), response.message());
}
}

View File

@@ -0,0 +1,18 @@
package io.shinhanlife.dap.mcc.infra.itrf.http.sample;
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpComponent;
import io.shinhanlife.dap.lib.integration.http.component.AxhubHttpDomain;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
/** Example of a Tool-specific HTTP client using the configured AX HUB HTTP domain. */
@Component
@RequiredArgsConstructor
public class SampleHttpApiClient {
private final AxhubHttpComponent http;
public SampleHttpApiResponse getStatus() {
return http.call(AxhubHttpDomain.SAMPLE, "/status", null, SampleHttpApiResponse.class);
}
}

View File

@@ -0,0 +1,5 @@
package io.shinhanlife.dap.mcc.infra.itrf.http.sample;
/** DTO matching the external API JSON response: { "status": "OK", "message": "..." }. */
public record SampleHttpApiResponse(String status, String message) {
}

View File

@@ -43,6 +43,14 @@ axhub:
url: http://localhost:8081
tool:
url: ${AXHUB_TOOL_URL:http://localhost:${server.port}}
# Registered outbound HTTP APIs. Tool code selects the domain name, never a free-form URL.
http:
api-list:
- name: sample
domain: ${AXHUB_SAMPLE_HTTP_DOMAIN:http://localhost:8099}
path: ""
method: GET
biz-pod: false
sol:
req-detail: