-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
책을 검색한다. #83
책을 검색한다. #83
Changes from 17 commits
a40d7fa
69038e2
70f1998
d729db4
f290d36
de425e7
1e28ede
40305ff
fd8f9cf
bd8fd85
31599da
11cefcd
a35dfcd
c67015a
add9f35
ce7ecf0
5963934
6a10cc5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
package com.dnd.sbooky.api.book; | ||
|
||
import com.dnd.sbooky.api.book.response.SearchBookResponse; | ||
import com.dnd.sbooky.api.docs.spec.SearchBookApiSpec; | ||
import com.dnd.sbooky.api.support.response.ApiResponse; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.web.bind.annotation.GetMapping; | ||
import org.springframework.web.bind.annotation.RequestMapping; | ||
import org.springframework.web.bind.annotation.RequestParam; | ||
import org.springframework.web.bind.annotation.RestController; | ||
|
||
@RestController | ||
@RequestMapping("/api") | ||
@RequiredArgsConstructor | ||
public class SearchBookController implements SearchBookApiSpec { | ||
|
||
private final SearchBookUseCase searchBookUseCase; | ||
|
||
@GetMapping("/books") | ||
public ApiResponse<SearchBookResponse> searchBook( | ||
@RequestParam(required = true) String query, | ||
@RequestParam(defaultValue = "accuracy", required = false) String sort, | ||
@RequestParam(defaultValue = "1", required = false) int page, | ||
@RequestParam(defaultValue = "10", required = false) int size, | ||
@RequestParam(required = false) String target) { | ||
|
||
// todo: 무작위한 검색을 막기 위해 사용자 검증이 필요할까? | ||
|
||
return ApiResponse.success(searchBookUseCase.search(query, sort, size, page, target)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
package com.dnd.sbooky.api.book; | ||
|
||
import com.dnd.sbooky.api.book.response.SearchBookResponse; | ||
import com.dnd.sbooky.clients.kakao.KakaoApiClient; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.stereotype.Service; | ||
|
||
@Service | ||
@RequiredArgsConstructor | ||
public class SearchBookUseCase { | ||
|
||
private final KakaoApiClient kakaoApiClient; | ||
|
||
public SearchBookResponse search(String query, String sort, int size, int page, String target) { | ||
return SearchBookResponse.from(kakaoApiClient.searchBooks(query, sort, page, size, target)); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
package com.dnd.sbooky.api.book.response; | ||
|
||
import com.dnd.sbooky.clients.kakao.response.KakaoSearchBookResponseDTO; | ||
import com.dnd.sbooky.clients.kakao.response.KakaoSearchBookResponseDTO.Meta; | ||
import io.swagger.v3.oas.annotations.media.Schema; | ||
import java.time.LocalDate; | ||
import java.util.List; | ||
|
||
@Schema(name = "SearchBookResponse", description = "책 검색 결과") | ||
public record SearchBookResponse( | ||
// spotless:off | ||
@Schema(name = "책 목록", description = "검색된 책 목록") | ||
List<Book> books, | ||
|
||
@Schema(name = "페이지 정보", description = "페이지 정보") | ||
PageInfo pageInfo) { | ||
|
||
public static SearchBookResponse from(KakaoSearchBookResponseDTO dto) { | ||
List<Book> books = dto.documents().stream() | ||
.map(document -> new Book( | ||
document.title(), | ||
document.authors(), | ||
document.datetime() == null | ||
? LocalDate.EPOCH : document.datetime().toLocalDate(), | ||
document.extractThumbnailFileName()) | ||
).toList(); | ||
|
||
Meta meta = dto.meta(); | ||
PageInfo pageInfo = new PageInfo(meta.is_end(), meta.pageable_count(), meta.total_count()); | ||
|
||
return new SearchBookResponse(books, pageInfo); | ||
} | ||
|
||
public record Book( | ||
@Schema(name = "책 제목") String title, | ||
@Schema(name = "저자 리스트") List<String> authors, | ||
@Schema(name = "출판일") LocalDate publishedAt, | ||
@Schema(name = "썸네일 URL") String thumbnail) { | ||
|
||
} | ||
|
||
public record PageInfo( | ||
@Schema(name = "마지막 페이지") boolean isEnd, | ||
@Schema(name = "중복된 책 제외 노출 가능 책 수") int pageableCount, | ||
@Schema(name = "검색된 책의 수") int totalCount) {} | ||
// spotless:on | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package com.dnd.sbooky.api.docs.spec; | ||
|
||
import com.dnd.sbooky.api.book.response.SearchBookResponse; | ||
import com.dnd.sbooky.api.support.response.ApiResponse; | ||
import io.swagger.v3.oas.annotations.Operation; | ||
import io.swagger.v3.oas.annotations.tags.Tag; | ||
import jakarta.validation.constraints.NotBlank; | ||
import jakarta.validation.constraints.Pattern; | ||
import org.hibernate.validator.constraints.Range; | ||
|
||
@Tag(name = "[Book API]", description = "책에 관련된 API") | ||
public interface SearchBookApiSpec { | ||
|
||
// spotless:off | ||
@Operation(summary = "책 검색", description = "책을 검색한다.") | ||
ApiResponse<SearchBookResponse> searchBook( | ||
@NotBlank(message = "검색어를 입력해주세요.") | ||
String query, | ||
|
||
@Pattern(regexp = "^(accuracy|latest)$", | ||
message = "정렬 방식은 accuracy 또는 latest 중 하나여야 합니다.") | ||
String sort, | ||
|
||
@Range(min = 1, max = 50, | ||
message = "페이지는 1 ~ 50 사이의 값이어야 합니다.") | ||
int page, | ||
|
||
@Range(min = 1, max = 50, | ||
message = "한 페이지당 문서 수는 1 ~ 50 사이의 값이어야 합니다.") | ||
int size, | ||
|
||
@Pattern(regexp = "^(title|isbn|publisher|person)$", | ||
message = "검색 필드는 title, isbn, publisher, person 중 하나여야 합니다.") | ||
String target | ||
); | ||
// spotless:on | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -7,6 +7,7 @@ spring: | |
- "core.yml" | ||
- "swagger-yml" | ||
- "security.yml" | ||
- "feign.yml" | ||
|
||
|
||
--- | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,6 @@ | ||
dependencies { | ||
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' | ||
implementation 'io.github.openfeign:feign-hc5' | ||
implementation 'io.github.openfeign:feign-micrometer' | ||
|
||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
package com.dnd.sbooky.clients.config; | ||
|
||
import org.springframework.cloud.openfeign.EnableFeignClients; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
@Configuration | ||
@EnableFeignClients(basePackages = "com.dnd.sbooky.clients") | ||
class FeignClientConfig {} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
package com.dnd.sbooky.clients.config; | ||
|
||
import lombok.Getter; | ||
import lombok.Setter; | ||
import org.springframework.boot.context.properties.ConfigurationProperties; | ||
import org.springframework.context.annotation.Configuration; | ||
|
||
@Getter | ||
@Setter | ||
@Configuration | ||
@ConfigurationProperties(prefix = "kakao.api") | ||
public class KakaoProperties { | ||
|
||
private String authorization; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
package com.dnd.sbooky.clients.kakao; | ||
|
||
import com.dnd.sbooky.clients.kakao.response.KakaoSearchBookResponseDTO; | ||
import org.springframework.cloud.openfeign.FeignClient; | ||
import org.springframework.web.bind.annotation.GetMapping; | ||
import org.springframework.web.bind.annotation.RequestParam; | ||
|
||
@FeignClient( | ||
name = "kakao-book-api", | ||
url = "https://dapi.kakao.com/v3", | ||
configuration = KakaoFeignClientConfig.class) | ||
public interface KakaoApiClient { | ||
|
||
/** | ||
* 카카오 책 검색 API | ||
* | ||
* @param query 검색어 (필수) | ||
* @param sort 정렬 방식 (accuracy, recency) - 기본값 accuracy | ||
* @param page 결과 페이지 번호 (1 ~ 50) - 기본값 1 | ||
* @param size 한 페이지에 보여질 문서의 개수 (1 ~ 50) - 기본값 10 | ||
* @param target 검색 필드 (title, isbn, publisher, person) - 기본값 X | ||
*/ | ||
@GetMapping("/search/book") | ||
KakaoSearchBookResponseDTO searchBooks( | ||
@RequestParam(value = "query", required = true) String query, | ||
@RequestParam(value = "sort", defaultValue = "accuracy", required = false) String sort, | ||
@RequestParam(value = "page", defaultValue = "1", required = false) int page, | ||
@RequestParam(value = "size", defaultValue = "10", required = false) int size, | ||
@RequestParam(value = "target", required = false) String target); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
package com.dnd.sbooky.clients.kakao; | ||
|
||
import com.dnd.sbooky.clients.config.KakaoProperties; | ||
import feign.RequestInterceptor; | ||
import lombok.RequiredArgsConstructor; | ||
import org.springframework.context.annotation.Bean; | ||
|
||
@RequiredArgsConstructor | ||
public class KakaoFeignClientConfig { | ||
|
||
private final KakaoProperties kakaoProperties; | ||
|
||
/** | ||
* Kakao API 호출 시 Authorization Header 추가하는 Interceptor | ||
*/ | ||
@Bean | ||
public RequestInterceptor authorizationInterceptor() { | ||
return requestTemplate -> | ||
requestTemplate.header("Authorization", "KakaoAK " + kakaoProperties.getAuthorization()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
package com.dnd.sbooky.clients.kakao.response; | ||
|
||
import java.net.URLDecoder; | ||
import java.nio.charset.StandardCharsets; | ||
import java.time.OffsetDateTime; | ||
import java.util.List; | ||
import org.apache.logging.log4j.util.Strings; | ||
|
||
public record KakaoSearchBookResponseDTO(List<Document> documents, Meta meta) { | ||
|
||
public record Document( | ||
String title, | ||
String contents, | ||
String isbn, | ||
String publisher, | ||
List<String> authors, | ||
String thumbnail, | ||
OffsetDateTime datetime, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저희 Time도 필요한가요?? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 제가 말하고자 한 것은 OffsetDateTime 클래스가 LocalDateTime에 Offset을 추가한 것인데, 여기서 LocalDate는 필요하지만 LocalTime도 필요한지가 궁금하다는 의미였습니다. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 해당 KakaoSearchResponseDTO 자체는 단순히 카카오에서 넘겨주는 정보들을 매핑하는 형태의 DTO로서 클라이언트에게 응답할 때는 api 모듈의 SearchBookResponse 에서 필요한 값과 형태로만 빼오는 방식으로 구성을 했어요
만약, 요구사항이 변경되어서 필요한 값이 추가된다면 단순히 api 모듈 내의 Reponse 객체만 수정하면 되도록 구성하는게 더 낫지 않을까? 라고 생각을 했습니다 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 아 확인했습니다! |
||
int price, | ||
int sale_price, | ||
String status, | ||
String url, | ||
List<String> translators) { | ||
|
||
/** | ||
* 썸네일 이미지 추출 코드 | ||
*/ | ||
public String extractThumbnailFileName() { | ||
|
||
if (Strings.isBlank(thumbnail)) { | ||
return ""; | ||
} | ||
|
||
try { | ||
String fileName = thumbnail.substring(thumbnail.indexOf("fname=") + 6); | ||
return URLDecoder.decode(fileName, StandardCharsets.UTF_8); | ||
} catch (Exception e) { | ||
return thumbnail; | ||
} | ||
} | ||
} | ||
|
||
public record Meta(boolean is_end, int pageable_count, int total_count) {} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
kakao: | ||
api: | ||
authorization: ${KAKAO_CLIENT_ID} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
해당 의존성들은 어디에서 사용되는지 궁금합니다.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
엇 의존성 찾아보면서 추가하다가 지워야되는걸 까먹었네요 😅
그런김에 찾아봤어요!
1. Apache HttpClient 5
이 의존성은 Feign에서 Apache HttpClient 5를 사용하는 의존성인거 같아요.
관련해서 찾아보니 토스에 이런 글이 있더라구요 (https://toss.tech/article/engineering-note-3)
요약하자면, Feign 내부적으로 기본 HTTP Client로
HttpURLConnection
을 사용하고 있어 동시성 문제가 발생할 수 있다고 하는데, JDK17 버전을 쓰고있는 상황에서는 이 문제가 해결되어서 괜찮다고 하네요 !2. Micrometer
이 의존성은 Feign 클라이언트의 메트릭을 수집하고 모니터링을 쉽게 적용할 수 있게 해주는 의존성이라고 해요.
Spring Boot Actuator와 함께 사용할 경우에 자동으로 애플리케이션 메트릭을 수집하고 보여준다고 하는데, 필요한 시점에 도입하는게 좋아보기인 합니다
두 의존성 모두 필요한 시점에 도입하는게 좋을 것 같아요! 의존성 제외하도록 하겠습니다 ~