feat: Mattermost REST 클라이언트 추가
WireMock(Jetty)의 h2c 업그레이드 협상이 POST 요청에서 RST_STREAM을 유발하는 문제가 있어, JDK HttpClient를 HTTP/1.1로 고정하도록 requestFactory를 명시적으로 지정했다(브리핑 예시 코드 대비 유일한 편차).
@62baed09b5401efa26a431c8f4c421f44e175124
--- src/main/java/kr/itn/itnhub/ItnHubApplication.java
+++ src/main/java/kr/itn/itnhub/ItnHubApplication.java
... | ... | @@ -2,8 +2,10 @@ |
| 2 | 2 |
|
| 3 | 3 |
import org.springframework.boot.SpringApplication; |
| 4 | 4 |
import org.springframework.boot.autoconfigure.SpringBootApplication; |
| 5 |
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan; |
|
| 5 | 6 |
|
| 6 | 7 |
@SpringBootApplication |
| 8 |
+@ConfigurationPropertiesScan |
|
| 7 | 9 |
public class ItnHubApplication {
|
| 8 | 10 |
public static void main(String[] args) {
|
| 9 | 11 |
SpringApplication.run(ItnHubApplication.class, args); |
+++ src/main/java/kr/itn/itnhub/config/MattermostProperties.java
... | ... | @@ -0,0 +1,13 @@ |
| 1 | +package kr.itn.itnhub.config; | |
| 2 | + | |
| 3 | +import org.springframework.boot.context.properties.ConfigurationProperties; | |
| 4 | +import org.springframework.boot.context.properties.bind.DefaultValue; | |
| 5 | + | |
| 6 | +@ConfigurationProperties(prefix = "mattermost") | |
| 7 | +public record MattermostProperties( | |
| 8 | + String baseUrl, | |
| 9 | + String token, | |
| 10 | + String teamId, | |
| 11 | + @DefaultValue("문정원") String channelNameMj, | |
| 12 | + @DefaultValue("법률검토") String channelNameLaw) { | |
| 13 | +} |
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostClient.java
... | ... | @@ -0,0 +1,12 @@ |
| 1 | +package kr.itn.itnhub.mattermost; | |
| 2 | + | |
| 3 | +import java.util.Optional; | |
| 4 | + | |
| 5 | +public interface MattermostClient { | |
| 6 | + | |
| 7 | + Optional<String> findChannelIdByInternalName(String internalName); | |
| 8 | + | |
| 9 | + Optional<String> findChannelIdByDisplayName(String displayName); | |
| 10 | + | |
| 11 | + String createPrivateChannel(String internalName, String displayName); | |
| 12 | +} |
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostException.java
... | ... | @@ -0,0 +1,11 @@ |
| 1 | +package kr.itn.itnhub.mattermost; | |
| 2 | + | |
| 3 | +public class MattermostException extends RuntimeException { | |
| 4 | + public MattermostException(String message, Throwable cause) { | |
| 5 | + super(message, cause); | |
| 6 | + } | |
| 7 | + | |
| 8 | + public MattermostException(String message) { | |
| 9 | + super(message); | |
| 10 | + } | |
| 11 | +} |
+++ src/main/java/kr/itn/itnhub/mattermost/MattermostRestClient.java
... | ... | @@ -0,0 +1,114 @@ |
| 1 | +package kr.itn.itnhub.mattermost; | |
| 2 | + | |
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | |
| 4 | +import kr.itn.itnhub.config.MattermostProperties; | |
| 5 | +import org.springframework.http.MediaType; | |
| 6 | +import org.springframework.http.client.JdkClientHttpRequestFactory; | |
| 7 | +import org.springframework.stereotype.Component; | |
| 8 | +import org.springframework.web.client.RestClient; | |
| 9 | +import org.springframework.web.client.RestClientException; | |
| 10 | + | |
| 11 | +import java.net.http.HttpClient; | |
| 12 | +import java.util.LinkedHashMap; | |
| 13 | +import java.util.Map; | |
| 14 | +import java.util.Optional; | |
| 15 | + | |
| 16 | +@Component | |
| 17 | +public class MattermostRestClient implements MattermostClient { | |
| 18 | + | |
| 19 | + private final RestClient rest; | |
| 20 | + private final String teamId; | |
| 21 | + | |
| 22 | + public MattermostRestClient(MattermostProperties props) { | |
| 23 | + this.teamId = props.teamId(); | |
| 24 | + // JDK HttpClient의 기본 HTTP/2 h2c 업그레이드 협상이 WireMock(Jetty) 상대로 | |
| 25 | + // POST 요청에서 간헐적으로 RST_STREAM을 유발해 HTTP/1.1로 고정한다. | |
| 26 | + HttpClient httpClient = HttpClient.newBuilder() | |
| 27 | + .version(HttpClient.Version.HTTP_1_1) | |
| 28 | + .build(); | |
| 29 | + this.rest = RestClient.builder() | |
| 30 | + .baseUrl(props.baseUrl()) | |
| 31 | + .requestFactory(new JdkClientHttpRequestFactory(httpClient)) | |
| 32 | + .defaultHeader("Authorization", "Bearer " + props.token()) | |
| 33 | + .build(); | |
| 34 | + } | |
| 35 | + | |
| 36 | + @Override | |
| 37 | + public Optional<String> findChannelIdByInternalName(String internalName) { | |
| 38 | + try { | |
| 39 | + JsonNode body = rest.get() | |
| 40 | + .uri("/api/v4/teams/{teamId}/channels/name/{name}", teamId, internalName) | |
| 41 | + .retrieve() | |
| 42 | + .onStatus(status -> status.value() == 404, (req, res) -> { | |
| 43 | + throw new NotFound(); | |
| 44 | + }) | |
| 45 | + .body(JsonNode.class); | |
| 46 | + | |
| 47 | + return idOf(body); | |
| 48 | + } catch (NotFound e) { | |
| 49 | + return Optional.empty(); | |
| 50 | + } catch (RestClientException e) { | |
| 51 | + throw new MattermostException("채널 조회 실패: " + internalName, e); | |
| 52 | + } | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Override | |
| 56 | + public Optional<String> findChannelIdByDisplayName(String displayName) { | |
| 57 | + try { | |
| 58 | + JsonNode channels = rest.get() | |
| 59 | + .uri(uri -> uri.path("/api/v4/users/me/teams/{teamId}/channels") | |
| 60 | + .build(teamId)) | |
| 61 | + .retrieve() | |
| 62 | + .body(JsonNode.class); | |
| 63 | + | |
| 64 | + if (channels == null || !channels.isArray()) { | |
| 65 | + return Optional.empty(); | |
| 66 | + } | |
| 67 | + for (JsonNode channel : channels) { | |
| 68 | + if (displayName.equals(channel.path("display_name").asText())) { | |
| 69 | + return idOf(channel); | |
| 70 | + } | |
| 71 | + } | |
| 72 | + return Optional.empty(); | |
| 73 | + } catch (RestClientException e) { | |
| 74 | + throw new MattermostException("채널 목록 조회 실패", e); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + @Override | |
| 79 | + public String createPrivateChannel(String internalName, String displayName) { | |
| 80 | + Map<String, String> body = new LinkedHashMap<>(); | |
| 81 | + body.put("team_id", teamId); | |
| 82 | + body.put("name", internalName); | |
| 83 | + body.put("display_name", displayName); | |
| 84 | + body.put("type", "P"); | |
| 85 | + body.put("purpose", ""); | |
| 86 | + body.put("header", ""); | |
| 87 | + | |
| 88 | + try { | |
| 89 | + JsonNode created = rest.post() | |
| 90 | + .uri("/api/v4/channels") | |
| 91 | + .contentType(MediaType.APPLICATION_JSON) | |
| 92 | + .body(body) | |
| 93 | + .retrieve() | |
| 94 | + .body(JsonNode.class); | |
| 95 | + | |
| 96 | + return idOf(created).orElseThrow(() -> | |
| 97 | + new MattermostException("채널 생성 응답에 id가 없습니다: " + displayName)); | |
| 98 | + } catch (RestClientException e) { | |
| 99 | + throw new MattermostException("채널 생성 실패: " + displayName, e); | |
| 100 | + } | |
| 101 | + } | |
| 102 | + | |
| 103 | + private Optional<String> idOf(JsonNode node) { | |
| 104 | + if (node == null) { | |
| 105 | + return Optional.empty(); | |
| 106 | + } | |
| 107 | + String id = node.path("id").asText(""); | |
| 108 | + return id.isBlank() ? Optional.empty() : Optional.of(id); | |
| 109 | + } | |
| 110 | + | |
| 111 | + /** 404를 정상 흐름으로 되돌리기 위한 내부 신호. 밖으로 새지 않는다. */ | |
| 112 | + private static final class NotFound extends RuntimeException { | |
| 113 | + } | |
| 114 | +} |
--- src/main/resources/application.yml
+++ src/main/resources/application.yml
... | ... | @@ -28,3 +28,8 @@ |
| 28 | 28 |
logging: |
| 29 | 29 |
level: |
| 30 | 30 |
kr.itn.itnhub: INFO |
| 31 |
+ |
|
| 32 |
+mattermost: |
|
| 33 |
+ base-url: ${MATTERMOST_URL}
|
|
| 34 |
+ token: ${MATTERMOST_TOKEN}
|
|
| 35 |
+ team-id: ${MATTERMOST_TEAM_ID}
|
+++ src/test/java/kr/itn/itnhub/mattermost/MattermostRestClientTest.java
... | ... | @@ -0,0 +1,108 @@ |
| 1 | +package kr.itn.itnhub.mattermost; | |
| 2 | + | |
| 3 | +import com.github.tomakehurst.wiremock.WireMockServer; | |
| 4 | +import kr.itn.itnhub.config.MattermostProperties; | |
| 5 | +import org.junit.jupiter.api.AfterEach; | |
| 6 | +import org.junit.jupiter.api.BeforeEach; | |
| 7 | +import org.junit.jupiter.api.Test; | |
| 8 | + | |
| 9 | +import static com.github.tomakehurst.wiremock.client.WireMock.*; | |
| 10 | +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.options; | |
| 11 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 12 | +import static org.assertj.core.api.Assertions.assertThatThrownBy; | |
| 13 | + | |
| 14 | +class MattermostRestClientTest { | |
| 15 | + | |
| 16 | + private WireMockServer server; | |
| 17 | + private MattermostRestClient client; | |
| 18 | + | |
| 19 | + @BeforeEach | |
| 20 | + void setUp() { | |
| 21 | + server = new WireMockServer(options().dynamicPort()); | |
| 22 | + server.start(); | |
| 23 | + | |
| 24 | + MattermostProperties props = new MattermostProperties( | |
| 25 | + "http://localhost:" + server.port(), | |
| 26 | + "test-token", | |
| 27 | + "team123", | |
| 28 | + "문정원", | |
| 29 | + "법률검토"); | |
| 30 | + | |
| 31 | + client = new MattermostRestClient(props); | |
| 32 | + } | |
| 33 | + | |
| 34 | + @AfterEach | |
| 35 | + void tearDown() { | |
| 36 | + server.stop(); | |
| 37 | + } | |
| 38 | + | |
| 39 | + @Test | |
| 40 | + void 내부명으로_채널을_찾으면_id를_돌려준다() { | |
| 41 | + server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj")) | |
| 42 | + .willReturn(okJson("{\"id\":\"chan001mj\",\"name\":\"org-001-mj\"}"))); | |
| 43 | + | |
| 44 | + assertThat(client.findChannelIdByInternalName("org-001-mj")) | |
| 45 | + .contains("chan001mj"); | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test | |
| 49 | + void 내부명으로_채널이_없으면_빈값이다() { | |
| 50 | + server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-999-mj")) | |
| 51 | + .willReturn(aResponse().withStatus(404))); | |
| 52 | + | |
| 53 | + assertThat(client.findChannelIdByInternalName("org-999-mj")).isEmpty(); | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Test | |
| 57 | + void 토큰을_Authorization_헤더로_보낸다() { | |
| 58 | + server.stubFor(get(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj")) | |
| 59 | + .willReturn(okJson("{\"id\":\"chan001mj\"}"))); | |
| 60 | + | |
| 61 | + client.findChannelIdByInternalName("org-001-mj"); | |
| 62 | + | |
| 63 | + server.verify(getRequestedFor(urlEqualTo("/api/v4/teams/team123/channels/name/org-001-mj")) | |
| 64 | + .withHeader("Authorization", equalTo("Bearer test-token"))); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void 표시명으로_내_채널목록에서_id를_찾는다() { | |
| 69 | + server.stubFor(get(urlPathEqualTo("/api/v4/users/me/teams/team123/channels")) | |
| 70 | + .willReturn(okJson(""" | |
| 71 | + [ | |
| 72 | + {"id":"aaa","display_name":"001_국제방송교류재단 (문정원)"}, | |
| 73 | + {"id":"bbb","display_name":"001_국제방송교류재단 (법률검토)"} | |
| 74 | + ] | |
| 75 | + """))); | |
| 76 | + | |
| 77 | + assertThat(client.findChannelIdByDisplayName("001_국제방송교류재단 (법률검토)")) | |
| 78 | + .contains("bbb"); | |
| 79 | + assertThat(client.findChannelIdByDisplayName("002_없는기관 (문정원)")) | |
| 80 | + .isEmpty(); | |
| 81 | + } | |
| 82 | + | |
| 83 | + @Test | |
| 84 | + void 비공개_채널을_생성하고_id를_돌려준다() { | |
| 85 | + server.stubFor(post(urlEqualTo("/api/v4/channels")) | |
| 86 | + .willReturn(okJson("{\"id\":\"newchan\"}"))); | |
| 87 | + | |
| 88 | + String id = client.createPrivateChannel("org-002-mj", "002_세종학당재단 (문정원)"); | |
| 89 | + | |
| 90 | + assertThat(id).isEqualTo("newchan"); | |
| 91 | + | |
| 92 | + server.verify(postRequestedFor(urlEqualTo("/api/v4/channels")) | |
| 93 | + .withRequestBody(matchingJsonPath("$.team_id", equalTo("team123"))) | |
| 94 | + .withRequestBody(matchingJsonPath("$.name", equalTo("org-002-mj"))) | |
| 95 | + .withRequestBody(matchingJsonPath("$.display_name", | |
| 96 | + equalTo("002_세종학당재단 (문정원)"))) | |
| 97 | + .withRequestBody(matchingJsonPath("$.type", equalTo("P")))); | |
| 98 | + } | |
| 99 | + | |
| 100 | + @Test | |
| 101 | + void 서버가_오류를_주면_MattermostException을_던진다() { | |
| 102 | + server.stubFor(post(urlEqualTo("/api/v4/channels")) | |
| 103 | + .willReturn(aResponse().withStatus(500).withBody("boom"))); | |
| 104 | + | |
| 105 | + assertThatThrownBy(() -> client.createPrivateChannel("org-003-mj", "003_기관 (문정원)")) | |
| 106 | + .isInstanceOf(MattermostException.class); | |
| 107 | + } | |
| 108 | +} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?