diff --git a/api/build.gradle b/api/build.gradle index e6170ee..71bc682 100644 --- a/api/build.gradle +++ b/api/build.gradle @@ -9,6 +9,7 @@ jar { dependencies { implementation project(':core') + implementation project(':clients') implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/api/src/main/java/com/dnd/sbooky/api/book/SearchBookController.java b/api/src/main/java/com/dnd/sbooky/api/book/SearchBookController.java new file mode 100644 index 0000000..001011c --- /dev/null +++ b/api/src/main/java/com/dnd/sbooky/api/book/SearchBookController.java @@ -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 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)); + } +} diff --git a/api/src/main/java/com/dnd/sbooky/api/book/SearchBookUseCase.java b/api/src/main/java/com/dnd/sbooky/api/book/SearchBookUseCase.java new file mode 100644 index 0000000..3a7ac46 --- /dev/null +++ b/api/src/main/java/com/dnd/sbooky/api/book/SearchBookUseCase.java @@ -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)); + } +} diff --git a/api/src/main/java/com/dnd/sbooky/api/book/response/SearchBookResponse.java b/api/src/main/java/com/dnd/sbooky/api/book/response/SearchBookResponse.java new file mode 100644 index 0000000..03c4933 --- /dev/null +++ b/api/src/main/java/com/dnd/sbooky/api/book/response/SearchBookResponse.java @@ -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 books, + + @Schema(name = "페이지 정보", description = "페이지 정보") + PageInfo pageInfo) { + + public static SearchBookResponse from(KakaoSearchBookResponseDTO dto) { + List 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 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 + +} diff --git a/api/src/main/java/com/dnd/sbooky/api/docs/spec/SearchBookApiSpec.java b/api/src/main/java/com/dnd/sbooky/api/docs/spec/SearchBookApiSpec.java new file mode 100644 index 0000000..435c831 --- /dev/null +++ b/api/src/main/java/com/dnd/sbooky/api/docs/spec/SearchBookApiSpec.java @@ -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 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 + +} diff --git a/api/src/main/java/com/dnd/sbooky/api/support/error/ApiControllerAdvice.java b/api/src/main/java/com/dnd/sbooky/api/support/error/ApiControllerAdvice.java index 49af533..ddfbe09 100644 --- a/api/src/main/java/com/dnd/sbooky/api/support/error/ApiControllerAdvice.java +++ b/api/src/main/java/com/dnd/sbooky/api/support/error/ApiControllerAdvice.java @@ -3,6 +3,7 @@ import com.dnd.sbooky.api.support.response.ApiResponse; import java.util.List; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.MessageSourceResolvable; import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -10,6 +11,7 @@ import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @Slf4j @@ -31,7 +33,6 @@ public ResponseEntity> handleApiException(ApiException e) { @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleMethodArgumentNotValidException( MethodArgumentNotValidException e) { - log.info("MethodArgumentNotValidException = {}", e.getMessage()); List errorMessages = e.getBindingResult().getFieldErrors().stream() @@ -73,6 +74,17 @@ public ResponseEntity> handleMissingServletRequestParameterExcept .body(ApiResponse.error(ErrorType.INVALID_PARAMETER)); } + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleHandlerMethodValidationException( + HandlerMethodValidationException e) { + + List errors = + e.getAllErrors().stream().map(MessageSourceResolvable::getDefaultMessage).toList(); + + return ResponseEntity.status(ErrorType.INVALID_PARAMETER.getStatus()) + .body(ApiResponse.error(ErrorType.INVALID_PARAMETER, errors)); + } + @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e) { log.error("Exception = {}", e.getMessage(), e); diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml index 655fc80..440826e 100644 --- a/api/src/main/resources/application.yml +++ b/api/src/main/resources/application.yml @@ -7,6 +7,7 @@ spring: - "core.yml" - "swagger-yml" - "security.yml" + - "feign.yml" --- diff --git a/build.gradle b/build.gradle index 1be55a0..2b715d0 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,12 @@ subprojects { bootJar.enabled = false // 실행가능한 jar jar.enabled = true // plain jar + dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudDependenciesVersion}" + } + } + dependencies { // lombok compileOnly 'org.projectlombok:lombok' // 컴파일 시점에만 사용 @@ -58,6 +64,7 @@ subprojects { .reorderImports(false) // import문 재정렬 비활성화 .groupArtifact('com.google.googlejavaformat:google-java-format') // 사용할 구글 포맷터 버전 지정 + toggleOffOn() // 코드 포맷팅 on/off 설정 indentWithTabs(2) // 탭을 사용한 들여쓰기 설정 (탭 크기 2) indentWithSpaces(4) // 공백을 사용한 들여쓰기 설정 (공백 4칸) importOrder() // import문 순서 설정 (기본값 사용) diff --git a/clients/build.gradle b/clients/build.gradle index 2e3ca6a..05c54e2 100644 --- a/clients/build.gradle +++ b/clients/build.gradle @@ -1,3 +1,3 @@ dependencies { - -} + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' +} \ No newline at end of file diff --git a/clients/src/main/java/com/dnd/sbooky/clients/config/FeignClientConfig.java b/clients/src/main/java/com/dnd/sbooky/clients/config/FeignClientConfig.java new file mode 100644 index 0000000..58b985b --- /dev/null +++ b/clients/src/main/java/com/dnd/sbooky/clients/config/FeignClientConfig.java @@ -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 {} diff --git a/clients/src/main/java/com/dnd/sbooky/clients/config/KakaoProperties.java b/clients/src/main/java/com/dnd/sbooky/clients/config/KakaoProperties.java new file mode 100644 index 0000000..16e41d5 --- /dev/null +++ b/clients/src/main/java/com/dnd/sbooky/clients/config/KakaoProperties.java @@ -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; +} diff --git a/clients/src/main/java/com/dnd/sbooky/clients/kakao/KakaoApiClient.java b/clients/src/main/java/com/dnd/sbooky/clients/kakao/KakaoApiClient.java new file mode 100644 index 0000000..6186aa1 --- /dev/null +++ b/clients/src/main/java/com/dnd/sbooky/clients/kakao/KakaoApiClient.java @@ -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); +} diff --git a/clients/src/main/java/com/dnd/sbooky/clients/kakao/KakaoFeignClientConfig.java b/clients/src/main/java/com/dnd/sbooky/clients/kakao/KakaoFeignClientConfig.java new file mode 100644 index 0000000..0f56ef8 --- /dev/null +++ b/clients/src/main/java/com/dnd/sbooky/clients/kakao/KakaoFeignClientConfig.java @@ -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()); + } +} diff --git a/clients/src/main/java/com/dnd/sbooky/clients/kakao/response/KakaoSearchBookResponseDTO.java b/clients/src/main/java/com/dnd/sbooky/clients/kakao/response/KakaoSearchBookResponseDTO.java new file mode 100644 index 0000000..e9f102a --- /dev/null +++ b/clients/src/main/java/com/dnd/sbooky/clients/kakao/response/KakaoSearchBookResponseDTO.java @@ -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 documents, Meta meta) { + + public record Document( + String title, + String contents, + String isbn, + String publisher, + List authors, + String thumbnail, + OffsetDateTime datetime, + int price, + int sale_price, + String status, + String url, + List 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) {} +} diff --git a/clients/src/main/resources/feign.yml b/clients/src/main/resources/feign.yml new file mode 100644 index 0000000..df53e5a --- /dev/null +++ b/clients/src/main/resources/feign.yml @@ -0,0 +1,3 @@ +kakao: + api: + authorization: ${KAKAO_CLIENT_ID} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 77f8da2..45533d5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,3 +8,4 @@ javaVersion=17 ### Spring Dependencies ### springBootVersion=3.3.7 springDependencyManagementVersion=1.1.7 +springCloudDependenciesVersion=2023.0.5