From 589eb1d6fa27934fc46c5af63bab5b9419a0e36a Mon Sep 17 00:00:00 2001 From: hseong3243 Date: Wed, 27 Mar 2024 14:18:34 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=8A=B8=EB=9D=BC=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=90=EB=A3=8C=20=EA=B5=AC=EC=A1=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seong/shoutlink/domain/common/Trie.java | 93 +++++++++++ .../shoutlink/domain/common/TrieTest.java | 154 ++++++++++++++++++ 2 files changed, 247 insertions(+) create mode 100644 src/main/java/com/seong/shoutlink/domain/common/Trie.java create mode 100644 src/test/java/com/seong/shoutlink/domain/common/TrieTest.java diff --git a/src/main/java/com/seong/shoutlink/domain/common/Trie.java b/src/main/java/com/seong/shoutlink/domain/common/Trie.java new file mode 100644 index 0000000..395e1b5 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/common/Trie.java @@ -0,0 +1,93 @@ +package com.seong.shoutlink.domain.common; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.Getter; + +@Getter +public class Trie { + + @Getter + static class Node { + + private final char c; + private final Map children = new HashMap<>(); + private boolean isWord; + + public Node(char c) { + this.c = c; + } + + public void setWord(boolean word) { + isWord = word; + } + + public boolean hasChildren(char c) { + return children.containsKey(c); + } + + public boolean isWord() { + return isWord; + } + + public void addSuggestions(String word, List suggestions, int count) { + children.forEach((character, childNode) -> { + String suggestionsWord = word + character; + if (childNode.isWord()) { + suggestions.add(suggestionsWord); + } + if (suggestions.size() >= count) { + return; + } + childNode.addSuggestions(suggestionsWord, suggestions, count); + }); + } + } + + private static final int MAX_SUGGESTION = 100; + private static final int MAX_WORD_LENGTH = 35; + private static final int MAX_PREFIX_LENGTH = 30; + public static final int ZERO = 0; + + private final Node root = new Node(' '); + + public synchronized void insert(String word) { + if(word.length() > MAX_WORD_LENGTH) { + return; + } + + Node currentNode = root; + for (char c : word.toCharArray()) { + Map children = currentNode.getChildren(); + currentNode = children.getOrDefault(c, new Node(c)); + children.put(c, currentNode); + } + currentNode.setWord(true); + } + + public List search(String prefix, int count) { + if(prefix.length() > MAX_PREFIX_LENGTH) { + prefix = prefix.substring(ZERO, MAX_PREFIX_LENGTH); + } + Node currentNode = root; + for (char c : prefix.toCharArray()) { + if (!currentNode.hasChildren(c)) { + return List.of(); + } + Map children = currentNode.getChildren(); + currentNode = children.get(c); + } + return findWords(prefix, currentNode, Math.min(MAX_SUGGESTION, count)); + } + + private List findWords(String word, Node currentNode, int count) { + List suggestions = new ArrayList<>(); + if (currentNode.isWord()) { + suggestions.add(word); + } + currentNode.addSuggestions(word, suggestions, count); + return suggestions; + } +} diff --git a/src/test/java/com/seong/shoutlink/domain/common/TrieTest.java b/src/test/java/com/seong/shoutlink/domain/common/TrieTest.java new file mode 100644 index 0000000..39c3b73 --- /dev/null +++ b/src/test/java/com/seong/shoutlink/domain/common/TrieTest.java @@ -0,0 +1,154 @@ +package com.seong.shoutlink.domain.common; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class TrieTest { + + @Nested + @DisplayName("insert 호출 시") + class InsertTest { + + @Test + @DisplayName("성공: 단어가 삽입된다.") + void insert() { + //given + Trie trie = new Trie(); + String word = "hello"; + + //when + trie.insert(word); + + //then + List result = trie.search("hello", 20); + assertThat(result).hasSize(1); + } + + @ParameterizedTest + @CsvSource({ + "35, 1", + "36, 0" + }) + @DisplayName("성공: 삽입할 수 있는 단어의 최대 길이는 35자이다.") + void insertableWordMaxLength_35(int wordLength, int resultSize) { + //given + Trie trie = new Trie(); + String word = "a".repeat(wordLength); + + //when + trie.insert(word); + + //then + List result = trie.search(word, 20); + assertThat(result).hasSize(resultSize); + } + + @RepeatedTest(20) + @DisplayName("성공: 동시에 삽입되어도 손실되지 않는다.") + void concurrentInsert() throws InterruptedException { + //given + Trie trie = new Trie(); + String prefix = "asdf"; + int threadPoolSize = 26; + ExecutorService service = Executors.newFixedThreadPool(threadPoolSize); + CountDownLatch latch = new CountDownLatch(threadPoolSize); + + //when + for (int i = 0; i < threadPoolSize; i++) { + int finalI = i; + service.submit(() -> { + try { + StringBuilder sb = new StringBuilder(prefix); + sb.append((char) ('a' + finalI)); + trie.insert(sb.toString()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + //then + List result = trie.search(prefix, 100); + assertThat(result).hasSize(threadPoolSize); + } + } + + @Nested + @DisplayName("search 호출 시") + class SearchTest { + + @Test + @DisplayName("성공: 검색어 목록을 반환한다.") + void returnWordList() { + //given + Trie trie = new Trie(); + String wordA = "hello"; + String wordB = "hell"; + String wordC = "helllllllssssso"; + trie.insert(wordA); + trie.insert(wordB); + trie.insert(wordC); + + //when + List result = trie.search("he", 30); + + //then + assertThat(result).containsExactlyInAnyOrder(wordA, wordB, wordC); + } + + @Test + @DisplayName("성공: 검색어 목록의 최대 길이는 100개이다.") + void maxWordListSize_100() { + //given + Trie trie = new Trie(); + String prefix = "asdf"; + StringBuilder sb = new StringBuilder(prefix); + for (int i = 0; i < 26; i++) { + sb.append((char) ('a' + i)); + for (int j = 0; j < 5; j++) { + sb.append((char) ('a' + j)); + trie.insert(sb.toString()); + sb.deleteCharAt(sb.length() - 1); + } + sb.deleteCharAt(sb.length() - 1); + } + + //when + List result = trie.search(prefix, 130); + + //then + assertThat(result).hasSize(100); + } + + @Test + @DisplayName("성공: 유효한 검색어의 길이는 최대 30자이다.") + void maxSearchPrefix_30() { + //given + Trie trie = new Trie(); + String prefix = "a".repeat(30); + trie.insert(prefix + "a"); + trie.insert(prefix + "b"); + trie.insert(prefix + "c"); + trie.insert(prefix + "d"); + trie.insert(prefix + "e"); + prefix += "abcde"; + + //when + List result = trie.search(prefix, 20); + + //then + assertThat(result).hasSize(5); + } + } +} From 66396c509d643cda56b123ea4b8edb6d117674ba Mon Sep 17 00:00:00 2001 From: hseong3243 Date: Wed, 27 Mar 2024 17:16:47 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EB=AA=A9=EB=A1=9D=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/DomainRepositoryImpl.java | 6 ++++ .../domain/service/DomainRepository.java | 3 ++ .../domain/domain/service/DomainService.java | 9 ++++++ .../request/FindRootDomainsCommand.java | 5 +++ .../response/FindRootDomainsResponse.java | 10 ++++++ .../repository/StubDomainRepository.java | 9 ++++++ .../domain/service/DomainServiceTest.java | 31 +++++++++++++++++++ 7 files changed, 73 insertions(+) create mode 100644 src/main/java/com/seong/shoutlink/domain/domain/service/request/FindRootDomainsCommand.java create mode 100644 src/main/java/com/seong/shoutlink/domain/domain/service/response/FindRootDomainsResponse.java diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java index 06bf45c..2890a07 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java @@ -2,6 +2,7 @@ import com.seong.shoutlink.domain.domain.Domain; import com.seong.shoutlink.domain.domain.service.DomainRepository; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -23,4 +24,9 @@ public Domain save(Domain domain) { return domainJpaRepository.save(DomainEntity.create(domain)) .toDomain(); } + + @Override + public List findRootDomains(String keyword, int size) { + return null; + } } diff --git a/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java b/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java index 4a310fd..ba9945d 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java @@ -1,6 +1,7 @@ package com.seong.shoutlink.domain.domain.service; import com.seong.shoutlink.domain.domain.Domain; +import java.util.List; import java.util.Optional; public interface DomainRepository { @@ -8,4 +9,6 @@ public interface DomainRepository { Optional findByRootDomain(String rootDomain); Domain save(Domain domain); + + List findRootDomains(String keyword, int size); } diff --git a/src/main/java/com/seong/shoutlink/domain/domain/service/DomainService.java b/src/main/java/com/seong/shoutlink/domain/domain/service/DomainService.java index b91bf0b..c926189 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/service/DomainService.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/service/DomainService.java @@ -1,13 +1,16 @@ package com.seong.shoutlink.domain.domain.service; import com.seong.shoutlink.domain.domain.Domain; +import com.seong.shoutlink.domain.domain.service.request.FindRootDomainsCommand; import com.seong.shoutlink.domain.domain.service.request.UpdateDomainCommand; +import com.seong.shoutlink.domain.domain.service.response.FindRootDomainsResponse; import com.seong.shoutlink.domain.domain.service.response.UpdateDomainResponse; import com.seong.shoutlink.domain.domain.util.DomainExtractor; import com.seong.shoutlink.domain.exception.ErrorCode; import com.seong.shoutlink.domain.exception.ShoutLinkException; import com.seong.shoutlink.domain.link.Link; import com.seong.shoutlink.domain.link.service.LinkRepository; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -34,4 +37,10 @@ public UpdateDomainResponse updateDomain(UpdateDomainCommand command) { linkRepository.updateLinkDomain(link, domain); return new UpdateDomainResponse(domain.getDomainId()); } + + public FindRootDomainsResponse findRootDomains(FindRootDomainsCommand command) { + List rootDomains = domainRepository.findRootDomains(command.keyword(), + command.size()); + return FindRootDomainsResponse.from(rootDomains); + } } diff --git a/src/main/java/com/seong/shoutlink/domain/domain/service/request/FindRootDomainsCommand.java b/src/main/java/com/seong/shoutlink/domain/domain/service/request/FindRootDomainsCommand.java new file mode 100644 index 0000000..aee0af5 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/domain/service/request/FindRootDomainsCommand.java @@ -0,0 +1,5 @@ +package com.seong.shoutlink.domain.domain.service.request; + +public record FindRootDomainsCommand(String keyword, int size) { + +} diff --git a/src/main/java/com/seong/shoutlink/domain/domain/service/response/FindRootDomainsResponse.java b/src/main/java/com/seong/shoutlink/domain/domain/service/response/FindRootDomainsResponse.java new file mode 100644 index 0000000..7fefcf7 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/domain/service/response/FindRootDomainsResponse.java @@ -0,0 +1,10 @@ +package com.seong.shoutlink.domain.domain.service.response; + +import java.util.List; + +public record FindRootDomainsResponse(List rootDomains) { + + public static FindRootDomainsResponse from(List rootDomains) { + return new FindRootDomainsResponse(rootDomains); + } +} diff --git a/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java b/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java index 675e7b3..68d432a 100644 --- a/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java +++ b/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java @@ -1,17 +1,21 @@ package com.seong.shoutlink.domain.domain.repository; +import com.seong.shoutlink.domain.common.Trie; import com.seong.shoutlink.domain.domain.Domain; import com.seong.shoutlink.domain.domain.service.DomainRepository; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; public class StubDomainRepository implements DomainRepository { private final Map memory = new HashMap<>(); + private final Trie searchAutoComplete = new Trie(); public void stub(Domain domain) { memory.put(nextId(), domain); + searchAutoComplete.insert(domain.getRootDomain()); } @Override @@ -31,4 +35,9 @@ public Domain save(Domain domain) { private Long nextId() { return (long) (memory.size() + 1); } + + @Override + public List findRootDomains(String keyword, int size) { + return searchAutoComplete.search(keyword, size); + } } diff --git a/src/test/java/com/seong/shoutlink/domain/domain/service/DomainServiceTest.java b/src/test/java/com/seong/shoutlink/domain/domain/service/DomainServiceTest.java index a676f44..a9368b9 100644 --- a/src/test/java/com/seong/shoutlink/domain/domain/service/DomainServiceTest.java +++ b/src/test/java/com/seong/shoutlink/domain/domain/service/DomainServiceTest.java @@ -5,7 +5,9 @@ import com.seong.shoutlink.domain.domain.Domain; import com.seong.shoutlink.domain.domain.repository.StubDomainRepository; +import com.seong.shoutlink.domain.domain.service.request.FindRootDomainsCommand; import com.seong.shoutlink.domain.domain.service.request.UpdateDomainCommand; +import com.seong.shoutlink.domain.domain.service.response.FindRootDomainsResponse; import com.seong.shoutlink.domain.domain.service.response.UpdateDomainResponse; import com.seong.shoutlink.domain.exception.ErrorCode; import com.seong.shoutlink.domain.exception.ShoutLinkException; @@ -82,4 +84,33 @@ void notFound_WhenLinkNotFound() { .isEqualTo(ErrorCode.NOT_FOUND); } } + + @Nested + @DisplayName("findRootDomains 호출 시") + class FindRootDomainsTest { + + @BeforeEach + void setUp() { + domainRepository = new StubDomainRepository(); + linkRepository = new FakeLinkRepository(); + domainService = new DomainService(domainRepository, linkRepository); + } + + @Test + @DisplayName("성공: 루트 도메인 문자열 목록을 반환한다.") + void findRootDomains() { + //given + String keyword = "git"; + FindRootDomainsCommand command = new FindRootDomainsCommand(keyword, 10); + String rootDomain = "github.com"; + Domain domain = DomainFixture.domain(rootDomain); + domainRepository.stub(domain); + + //when + FindRootDomainsResponse response = domainService.findRootDomains(command); + + //then + assertThat(response.rootDomains()).containsExactly(rootDomain); + } + } } \ No newline at end of file From fefbeb2179d816b2df663d8bf43a6e9525cb67bb Mon Sep 17 00:00:00 2001 From: hseong3243 Date: Wed, 27 Mar 2024 17:22:59 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EC=9E=90=EB=8F=99=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=20=EC=98=81=EC=86=8D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/DomainCacheRepository.java | 8 ++++++++ .../repository/DomainMemoryRepository.java | 16 ++++++++++++++++ .../domain/repository/DomainRepositoryImpl.java | 3 ++- 3 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java create mode 100644 src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java new file mode 100644 index 0000000..9004436 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java @@ -0,0 +1,8 @@ +package com.seong.shoutlink.domain.domain.repository; + +import java.util.List; + +public interface DomainCacheRepository { + + List findRootDomains(String prefix, int count); +} diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java new file mode 100644 index 0000000..808296e --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java @@ -0,0 +1,16 @@ +package com.seong.shoutlink.domain.domain.repository; + +import com.seong.shoutlink.domain.common.Trie; +import java.util.List; +import org.springframework.stereotype.Repository; + +@Repository +public class DomainMemoryRepository implements DomainCacheRepository { + + private final Trie trie = new Trie(); + + @Override + public List findRootDomains(String prefix, int count) { + return trie.search(prefix, count); + } +} diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java index 2890a07..d8f53c2 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java @@ -12,6 +12,7 @@ public class DomainRepositoryImpl implements DomainRepository { private final DomainJpaRepository domainJpaRepository; + private final DomainCacheRepository domainCacheRepository; @Override public Optional findByRootDomain(String rootDomain) { @@ -27,6 +28,6 @@ public Domain save(Domain domain) { @Override public List findRootDomains(String keyword, int size) { - return null; + return domainCacheRepository.findRootDomains(keyword, size); } } From ee5c94a247a55bf3d1f855ce3ed5b79fb8c478a1 Mon Sep 17 00:00:00 2001 From: hseong3243 Date: Wed, 27 Mar 2024 18:31:26 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=96=B4=20=EC=9E=90=EB=8F=99=20=EC=99=84?= =?UTF-8?q?=EC=84=B1=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/index.adoc | 12 ++ .../domain/controller/DomainController.java | 29 +++++ .../request/FindRootDomainsRequest.java | 17 +++ src/main/resources/static/docs/index.html | 114 ++++++++++++++++-- .../shoutlink/base/BaseControllerTest.java | 4 + .../controller/DomainControllerTest.java | 51 ++++++++ 6 files changed, 217 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/seong/shoutlink/domain/domain/controller/DomainController.java create mode 100644 src/main/java/com/seong/shoutlink/domain/domain/controller/request/FindRootDomainsRequest.java create mode 100644 src/test/java/com/seong/shoutlink/domain/domain/controller/DomainControllerTest.java diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index c8c3f2d..cd3a6a3 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -140,3 +140,15 @@ operation::hub-controller-test/find-hub[snippets='http-request,path-parameters'] ==== response operation::hub-controller-test/find-hub[snippets='http-response'] + +== 도메인 + +=== 루트 도메인 목록 조회 + +==== request + +operation::domain-controller-test/find-root-domains[snippets='http-request,query-parameters'] + +==== response + +operation::domain-controller-test/find-root-domains[snippets='http-response,response-fields'] diff --git a/src/main/java/com/seong/shoutlink/domain/domain/controller/DomainController.java b/src/main/java/com/seong/shoutlink/domain/domain/controller/DomainController.java new file mode 100644 index 0000000..882ad5e --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/domain/controller/DomainController.java @@ -0,0 +1,29 @@ +package com.seong.shoutlink.domain.domain.controller; + +import com.seong.shoutlink.domain.domain.controller.request.FindRootDomainsRequest; +import com.seong.shoutlink.domain.domain.service.DomainService; +import com.seong.shoutlink.domain.domain.service.request.FindRootDomainsCommand; +import com.seong.shoutlink.domain.domain.service.response.FindRootDomainsResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/domains") +public class DomainController { + + private final DomainService domainService; + + @GetMapping("/search") + public ResponseEntity findRootDomains( + @ModelAttribute @Valid FindRootDomainsRequest request) { + FindRootDomainsResponse response = domainService.findRootDomains( + new FindRootDomainsCommand(request.keyword(), request.size())); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/seong/shoutlink/domain/domain/controller/request/FindRootDomainsRequest.java b/src/main/java/com/seong/shoutlink/domain/domain/controller/request/FindRootDomainsRequest.java new file mode 100644 index 0000000..fcafaf4 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/domain/domain/controller/request/FindRootDomainsRequest.java @@ -0,0 +1,17 @@ +package com.seong.shoutlink.domain.domain.controller.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.Objects; + +public record FindRootDomainsRequest( + @NotNull(message = "검색어는 필수입니다.") + @Size(min = 1, message = "검색어는 1자 이상이어야 합니다.") + String keyword, + Integer size) { + + public FindRootDomainsRequest(String keyword, Integer size) { + this.keyword = keyword; + this.size = Objects.isNull(size) ? 10 : size; + } +} diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 19c1b18..0d89930 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -479,6 +479,11 @@

API 문서

  • 허브 조회
  • +
  • 도메인 + +
  • @@ -709,7 +714,7 @@
    POST /api/link-bundles HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQyLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQyLCJyb2xlIjoiUk9MRV9VU0VSIn0.02TJ22pSXSTrGOSFF5dPId_sN6IUrNczAUFJSzcAIFE
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Content-Length: 57
     Host: localhost:8080
     
    @@ -825,7 +830,7 @@ 
    GET /api/link-bundles HTTP/1.1
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQyLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQyLCJyb2xlIjoiUk9MRV9VU0VSIn0.02TJ22pSXSTrGOSFF5dPId_sN6IUrNczAUFJSzcAIFE
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Host: localhost:8080
    @@ -930,7 +935,7 @@
    POST /api/hubs/1/link-bundles HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQyLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQyLCJyb2xlIjoiUk9MRV9VU0VSIn0.02TJ22pSXSTrGOSFF5dPId_sN6IUrNczAUFJSzcAIFE
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Content-Length: 53
     Host: localhost:8080
     
    @@ -1068,7 +1073,7 @@ 
    GET /api/hubs/1/link-bundles HTTP/1.1
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQyLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQyLCJyb2xlIjoiUk9MRV9VU0VSIn0.02TJ22pSXSTrGOSFF5dPId_sN6IUrNczAUFJSzcAIFE
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Host: localhost:8080
    @@ -1196,7 +1201,7 @@
    POST /api/links HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQyLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQyLCJyb2xlIjoiUk9MRV9VU0VSIn0.02TJ22pSXSTrGOSFF5dPId_sN6IUrNczAUFJSzcAIFE
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Content-Length: 100
     Host: localhost:8080
     
    @@ -1318,7 +1323,7 @@ 
    GET /api/links?linkBundleId=1&page=0&size=10 HTTP/1.1
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQyLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQyLCJyb2xlIjoiUk9MRV9VU0VSIn0.02TJ22pSXSTrGOSFF5dPId_sN6IUrNczAUFJSzcAIFE
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Host: localhost:8080
    @@ -1385,7 +1390,7 @@
    POST /api/hubs/1/links HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQxLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQxLCJyb2xlIjoiUk9MRV9VU0VSIn0.pAyb8fsrReSTc_5r3qBoVymRaxvkn9DSdqjy_puYFt8
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Content-Length: 69
     Host: localhost:8080
     
    @@ -1529,7 +1534,7 @@ 
    GET /api/hubs/1/links?linkBundleId=1&page=0&size=20 HTTP/1.1
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQxLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQxLCJyb2xlIjoiUk9MRV9VU0VSIn0.pAyb8fsrReSTc_5r3qBoVymRaxvkn9DSdqjy_puYFt8
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Host: localhost:8080
    @@ -1698,7 +1703,7 @@
    POST /api/hubs HTTP/1.1
     Content-Type: application/json;charset=UTF-8
    -Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzA4NzAxMzQxLCJzdWIiOiIxIiwiZXhwIjoxNzA4NzA0OTQxLCJyb2xlIjoiUk9MRV9VU0VSIn0.pAyb8fsrReSTc_5r3qBoVymRaxvkn9DSdqjy_puYFt8
    +Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzExNTMxNzM2LCJzdWIiOiIxIiwiZXhwIjoxNzExNTM1MzM2LCJyb2xlIjoiUk9MRV9VU0VSIn0.tgCEJM4UvjZH9-NHngy3wbLvlBrS27777kJBEhKMuAc
     Content-Length: 88
     Host: localhost:8080
     
    @@ -2003,11 +2008,100 @@ 
    +

    도메인

    +
    +
    +

    루트 도메인 목록 조회

    +
    +

    request

    +
    +
    HTTP request
    +
    +
    +
    GET /api/domains/search?keyword=searchKeyword&size=20 HTTP/1.1
    +Host: localhost:8080
    +
    +
    +
    +
    +
    Query parameters
    + ++++ + + + + + + + + + + + + + + + + +
    ParameterDescription

    keyword

    검색어

    size

    검색어 목록 사이즈

    +
    +
    +
    +

    response

    +
    +
    HTTP response
    +
    +
    +
    HTTP/1.1 200 OK
    +Vary: Origin
    +Vary: Access-Control-Request-Method
    +Vary: Access-Control-Request-Headers
    +Content-Type: application/json;charset=UTF-8
    +Content-Length: 38
    +
    +{
    +  "rootDomains" : [ "github.com" ]
    +}
    +
    +
    +
    +
    +
    Response fields
    + +++++ + + + + + + + + + + + + + + +
    PathTypeDescription

    rootDomains

    Array

    루트 도메인 목록

    +
    +
    +
    +
    + diff --git a/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java b/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java index 0dba222..21ae788 100644 --- a/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java +++ b/src/test/java/com/seong/shoutlink/base/BaseControllerTest.java @@ -6,6 +6,7 @@ import com.seong.shoutlink.base.BaseControllerTest.BaseControllerConfig; import com.seong.shoutlink.domain.auth.JwtProvider; import com.seong.shoutlink.domain.auth.service.AuthService; +import com.seong.shoutlink.domain.domain.service.DomainService; import com.seong.shoutlink.domain.hub.service.HubService; import com.seong.shoutlink.domain.link.service.LinkService; import com.seong.shoutlink.domain.linkbundle.service.LinkBundleService; @@ -77,6 +78,9 @@ public AuthenticationContext authenticationContext() { @MockBean protected HubService hubService; + @MockBean + protected DomainService domainService; + @BeforeEach void setUp( WebApplicationContext applicationContext, diff --git a/src/test/java/com/seong/shoutlink/domain/domain/controller/DomainControllerTest.java b/src/test/java/com/seong/shoutlink/domain/domain/controller/DomainControllerTest.java new file mode 100644 index 0000000..3ef92c4 --- /dev/null +++ b/src/test/java/com/seong/shoutlink/domain/domain/controller/DomainControllerTest.java @@ -0,0 +1,51 @@ +package com.seong.shoutlink.domain.domain.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.get; +import static org.springframework.restdocs.payload.JsonFieldType.ARRAY; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.seong.shoutlink.base.BaseControllerTest; +import com.seong.shoutlink.domain.domain.service.response.FindRootDomainsResponse; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +class DomainControllerTest extends BaseControllerTest { + + @Test + @DisplayName("루트 도메인 자동 완성 API 호출 시") + void findRootDomains() throws Exception { + //given + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("keyword", "searchKeyword"); + params.add("size", "20"); + FindRootDomainsResponse response = new FindRootDomainsResponse(List.of("github.com")); + + given(domainService.findRootDomains(any())).willReturn(response); + + //when + ResultActions resultActions = mockMvc.perform(get("/api/domains/search") + .params(params)); + + //then + resultActions.andExpect(status().isOk()) + .andDo(restDocs.document( + queryParameters( + parameterWithName("keyword").description("검색어"), + parameterWithName("size").description("검색어 목록 사이즈") + ), + responseFields( + fieldWithPath("rootDomains").type(ARRAY).description("루트 도메인 목록") + ) + )); + } +} \ No newline at end of file From e8136e945b75ac7450c4fab4cd5b97338b25a1b8 Mon Sep 17 00:00:00 2001 From: hseong3243 Date: Wed, 27 Mar 2024 18:46:32 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20=EB=A3=A8=ED=8A=B8=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EB=AA=A9=EB=A1=9D=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/DomainCacheRepository.java | 2 ++ .../domain/repository/DomainJpaRepository.java | 5 +++++ .../repository/DomainMemoryRepository.java | 5 +++++ .../domain/repository/DomainRepositoryImpl.java | 6 ++++++ .../domain/domain/service/DomainRepository.java | 2 ++ .../global/config/SchedulerConfig.java | 17 +++++++++++++++++ .../global/scheduler/DomainScheduler.java | 16 ++++++++++++++++ .../domain/repository/StubDomainRepository.java | 7 +++++++ 8 files changed, 60 insertions(+) create mode 100644 src/main/java/com/seong/shoutlink/global/config/SchedulerConfig.java create mode 100644 src/main/java/com/seong/shoutlink/global/scheduler/DomainScheduler.java diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java index 9004436..4a69c9d 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainCacheRepository.java @@ -5,4 +5,6 @@ public interface DomainCacheRepository { List findRootDomains(String prefix, int count); + + void insert(String rootDomain); } diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainJpaRepository.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainJpaRepository.java index 2d0924c..ff5997b 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainJpaRepository.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainJpaRepository.java @@ -1,9 +1,14 @@ package com.seong.shoutlink.domain.domain.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface DomainJpaRepository extends JpaRepository { Optional findByRootDomain(String rootDomain); + + @Query("select d.rootDomain from DomainEntity d") + List findRootDomains(); } diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java index 808296e..225df5f 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainMemoryRepository.java @@ -13,4 +13,9 @@ public class DomainMemoryRepository implements DomainCacheRepository { public List findRootDomains(String prefix, int count) { return trie.search(prefix, count); } + + @Override + public void insert(String rootDomain) { + trie.insert(rootDomain); + } } diff --git a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java index d8f53c2..3ef751b 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/repository/DomainRepositoryImpl.java @@ -30,4 +30,10 @@ public Domain save(Domain domain) { public List findRootDomains(String keyword, int size) { return domainCacheRepository.findRootDomains(keyword, size); } + + @Override + public void synchronizeRootDomains() { + domainJpaRepository.findRootDomains().forEach(domainCacheRepository::insert); + + } } diff --git a/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java b/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java index ba9945d..3709b94 100644 --- a/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java +++ b/src/main/java/com/seong/shoutlink/domain/domain/service/DomainRepository.java @@ -11,4 +11,6 @@ public interface DomainRepository { Domain save(Domain domain); List findRootDomains(String keyword, int size); + + void synchronizeRootDomains(); } diff --git a/src/main/java/com/seong/shoutlink/global/config/SchedulerConfig.java b/src/main/java/com/seong/shoutlink/global/config/SchedulerConfig.java new file mode 100644 index 0000000..94460e8 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/config/SchedulerConfig.java @@ -0,0 +1,17 @@ +package com.seong.shoutlink.global.config; + +import com.seong.shoutlink.domain.domain.service.DomainRepository; +import com.seong.shoutlink.global.scheduler.DomainScheduler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + + @Bean + public DomainScheduler domainScheduler(DomainRepository domainRepository) { + return new DomainScheduler(domainRepository); + } +} diff --git a/src/main/java/com/seong/shoutlink/global/scheduler/DomainScheduler.java b/src/main/java/com/seong/shoutlink/global/scheduler/DomainScheduler.java new file mode 100644 index 0000000..70aee82 --- /dev/null +++ b/src/main/java/com/seong/shoutlink/global/scheduler/DomainScheduler.java @@ -0,0 +1,16 @@ +package com.seong.shoutlink.global.scheduler; + +import com.seong.shoutlink.domain.domain.service.DomainRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; + +@RequiredArgsConstructor +public class DomainScheduler { + + private final DomainRepository domainRepository; + + @Scheduled(cron = "0 0 * * * *") + public void synchronizeRootDomains() { + domainRepository.synchronizeRootDomains(); + } +} diff --git a/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java b/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java index 68d432a..e10010c 100644 --- a/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java +++ b/src/test/java/com/seong/shoutlink/domain/domain/repository/StubDomainRepository.java @@ -40,4 +40,11 @@ private Long nextId() { public List findRootDomains(String keyword, int size) { return searchAutoComplete.search(keyword, size); } + + @Override + public void synchronizeRootDomains() { + for (Domain domain : memory.values()) { + searchAutoComplete.insert(domain.getRootDomain()); + } + } }