feat: add Glow HTTP tool integration sample
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 14s
Some checks failed
Deploy to OCIWP / deploy (push) Failing after 14s
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user