diff --git a/admin/src/main/resources/application.yml b/admin/src/main/resources/application.yml index 83770259..3c8257e4 100644 --- a/admin/src/main/resources/application.yml +++ b/admin/src/main/resources/application.yml @@ -9,10 +9,12 @@ spring: - persistence - jwt - redis + - external prod: - persistence - jwt - redis + - external test: - persistence - jwt diff --git a/apis/build.gradle.kts b/apis/build.gradle.kts index 7032e28b..e266dbf5 100644 --- a/apis/build.gradle.kts +++ b/apis/build.gradle.kts @@ -7,7 +7,6 @@ dependencies { implementation(project(Dependencies.Projects.GATEWAY)) implementation(Dependencies.Spring.BOOT_STARTER_WEB) - testImplementation(Dependencies.Spring.BOOT_STARTER_TEST) implementation(Dependencies.Spring.BOOT_STARTER_DATA_JPA) implementation(Dependencies.Spring.BOOT_STARTER_SECURITY) implementation(Dependencies.Spring.BOOT_STARTER_VALIDATION) @@ -22,11 +21,12 @@ dependencies { implementation(Dependencies.Swagger.SPRINGDOC_OPENAPI_STARTER_WEBMVC_UI) implementation(Dependencies.Logging.KOTLIN_LOGGING) - implementation(Dependencies.Feign.STARTER_OPENFEIGN) + testImplementation(Dependencies.Spring.BOOT_STARTER_TEST) testImplementation(Dependencies.TestContainers.MYSQL) testImplementation(Dependencies.TestContainers.JUNIT_JUPITER) testImplementation(Dependencies.TestContainers.REDIS) + } tasks { diff --git a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt new file mode 100644 index 00000000..e64d27b1 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookController.kt @@ -0,0 +1,35 @@ +package org.yapp.apis.book.controller + +import jakarta.validation.Valid +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 +import org.yapp.apis.book.dto.request.BookDetailRequest +import org.yapp.apis.book.dto.request.BookSearchRequest +import org.yapp.apis.book.dto.response.BookDetailResponse +import org.yapp.apis.book.dto.response.BookSearchResponse +import org.yapp.apis.book.usecase.BookUseCase + + +@RestController +@RequestMapping("/api/v1/books") +class BookController( + private val bookUseCase: BookUseCase +) : BookControllerApi { + + @GetMapping("/search") + override fun searchBooks(@Valid @ModelAttribute request: BookSearchRequest): ResponseEntity { + val response = bookUseCase.searchBooks(request) + return ResponseEntity.ok(response) + } + + @GetMapping("/detail") + override fun getBookDetail( + @Valid @ModelAttribute request: BookDetailRequest + ): ResponseEntity { + val response = bookUseCase.getBookDetail(request) + return ResponseEntity.ok(response) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt new file mode 100644 index 00000000..501a0f05 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/controller/BookControllerApi.kt @@ -0,0 +1,113 @@ +package org.yapp.apis.book.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.ExampleObject +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.yapp.apis.book.dto.request.BookDetailRequest +import org.yapp.apis.book.dto.request.BookSearchRequest +import org.yapp.apis.book.dto.response.BookDetailResponse +import org.yapp.apis.book.dto.response.BookSearchResponse + +/** + * API interface for book controller. + */ +@Tag(name = "Books", description = "도서 정보를 조회하는 API") +@RequestMapping("/api/v1/books") +interface BookControllerApi { + + @Operation( + summary = "도서 검색", + description = "키워드를 사용하여 알라딘 도서 정보를 검색합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "성공적인 검색", + content = [Content(schema = Schema(implementation = BookSearchResponse::class))] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 파라미터" + ) + ] + ) + @GetMapping("/search") + fun searchBooks( + @Valid + @Parameter( + description = "도서 검색 요청 객체. 다음 쿼리 파라미터를 포함합니다:
" + + "- `query` (필수): 검색어
" + + "- `queryType` (선택): 검색어 타입 (예: Title, Author). 기본값은 All
" + + "- `maxResults` (선택): 한 페이지당 결과 개수 (1-50). 기본값 10
" + + "- `start` (선택): 결과 시작 페이지. 기본값 1
" + + "- `sort` (선택): 정렬 방식 (예: PublishTime, SalesPoint). 기본값 Accuracy
" + + "- `categoryId` (선택): 카테고리 ID", + examples = [ + ExampleObject(name = "기본 검색", value = "http://localhost:8080/api/v1/books/search?query=코틀린"), + ExampleObject( + name = "상세 검색", + value = "http://localhost:8080/api/v1/books/search?query=클린코드&queryType=Title&maxResults=10&sort=PublishTime" + ), + ExampleObject( + name = "카테고리 검색", + value = "http://localhost:8080/api/v1/books/search?query=Spring&categoryId=170&start=2&maxResults=5" + ) + ] + ) + request: BookSearchRequest + ): ResponseEntity + + + @Operation( + summary = "도서 상세 조회", + description = "특정 도서의 상세 정보를 조회합니다. `itemId`는 쿼리 파라미터로 전달됩니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "성공적으로 도서 상세 정보를 조회했습니다.", + content = [Content(schema = Schema(implementation = BookDetailResponse::class))] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청 파라미터 (예: 유효하지 않은 itemId 또는 itemIdType)" + ), + ApiResponse( + responseCode = "404", + description = "해당하는 itemId를 가진 도서를 찾을 수 없습니다." + ) + ] + ) + @GetMapping("/detail") + fun getBookDetail( + @Valid + @Parameter( + description = "도서 상세 조회 요청 객체. 다음 쿼리 파라미터를 포함합니다:
" + + "- `itemId` (필수): 조회할 도서의 고유 ID (ISBN, ISBN13, 알라딘 ItemId 등)
" + + "- `itemIdType` (선택): `itemId`의 타입 (ISBN, ISBN13, ItemId). 기본값은 ISBN입니다.
" + + "- `optResult` (선택): 조회할 부가 정보 목록 (쉼표로 구분). 예시: `BookInfo,Toc,PreviewImg`", + examples = [ + ExampleObject( + name = "ISBN으로 상세 조회", + value = "http://localhost:8080/api/v1/books/detail?itemId=9791162241684&itemIdType=ISBN13" + ), + ExampleObject( + name = "ISBN 및 부가 정보 포함", + value = "http://localhost:8080/api/v1/books/detail?itemId=8994492040&itemIdType=ISBN&optResult=BookInfo,Toc" + ) + ] + ) + request: BookDetailRequest + ): ResponseEntity +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookDetailRequest.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookDetailRequest.kt new file mode 100644 index 00000000..bbbdcb56 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookDetailRequest.kt @@ -0,0 +1,25 @@ +package org.yapp.apis.book.dto.request + +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Pattern // Pattern 어노테이션 추가 +import org.yapp.globalutils.util.RegexUtils +import org.yapp.infra.external.aladin.dto.AladinBookLookupRequest + +data class BookDetailRequest private constructor( + @field:NotBlank(message = "아이템 ID는 필수입니다.") + @field:Pattern( + regexp = RegexUtils.NOT_BLANK_AND_NOT_NULL_STRING_PATTERN, + message = "아이템 ID는 유효한 ISBN 형식이 아닙니다." + ) + val itemId: String? = null, + val itemIdType: String? = "ISBN", + val optResult: List? = null +) { + fun toAladinRequest(): AladinBookLookupRequest { + return AladinBookLookupRequest.create( + itemId = this.itemId!!, + itemIdType = this.itemIdType ?: "ISBN", + optResult = this.optResult + ) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookSearchRequest.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookSearchRequest.kt new file mode 100644 index 00000000..6fb7a7ff --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/request/BookSearchRequest.kt @@ -0,0 +1,31 @@ +package org.yapp.apis.book.dto.request + +import org.yapp.infra.external.aladin.dto.AladinBookSearchRequest + + +data class BookSearchRequest private constructor( + val query: String? = null, + val queryType: String? = null, + val searchTarget: String? = null, + val maxResults: Int? = null, + val start: Int? = null, + val sort: String? = null, + val cover: String? = null, + val categoryId: Int? = null +) { + + fun validQuery(): String = query!! + fun toAladinRequest(): AladinBookSearchRequest { + + return AladinBookSearchRequest.create( + query = this.validQuery(), + queryType = this.queryType, + searchTarget = this.searchTarget, + maxResults = this.maxResults, + start = this.start, + sort = this.sort, + cover = this.cover, + categoryId = this.categoryId + ) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookDetailResponse.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookDetailResponse.kt new file mode 100644 index 00000000..ef208be8 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookDetailResponse.kt @@ -0,0 +1,60 @@ +package org.yapp.apis.book.dto.response + +import org.yapp.infra.external.aladin.response.AladinBookDetailResponse +import java.math.BigDecimal + +/** + * 단일 도서의 상세 정보를 나타내는 DTO. + * 외부 API 응답 및 도메인 Book 객체로부터 변환됩니다. + */ +data class BookDetailResponse private constructor( + val version: String?, + val title: String, + val link: String?, + val author: String?, + val pubDate: String?, + val description: String?, + val isbn: String?, + val isbn13: String?, + val itemId: Long?, + val priceSales: BigDecimal?, + val priceStandard: BigDecimal?, + val mallType: String?, + val stockStatus: String?, + val mileage: Int?, + val cover: String?, + val categoryId: Int?, + val categoryName: String?, + val publisher: String? +) { + companion object { + /** + * AladinBookDetailResponse와 Book 도메인 객체로부터 BookDetailResponse를 생성합니다. + */ + fun from(response: AladinBookDetailResponse): BookDetailResponse { + val bookItem = response.item?.firstOrNull() + ?: throw IllegalArgumentException("No book item found in detail response.") + + return BookDetailResponse( + version = response.version, + title = bookItem.title ?: "", + link = bookItem.link, + author = bookItem.author ?: "", + pubDate = bookItem.pubDate, + description = bookItem.description ?: "", + isbn = bookItem.isbn ?: bookItem.isbn13 ?: "", + isbn13 = bookItem.isbn13, + itemId = bookItem.itemId, + priceSales = bookItem.priceSales, + priceStandard = bookItem.priceStandard, + mallType = bookItem.mallType, + stockStatus = bookItem.stockStatus, + mileage = bookItem.mileage, + cover = bookItem.cover ?: "", + categoryId = bookItem.categoryId, + categoryName = bookItem.categoryName, + publisher = bookItem.publisher ?: "", + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookSearchResponse.kt b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookSearchResponse.kt new file mode 100644 index 00000000..ada62b8b --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/dto/response/BookSearchResponse.kt @@ -0,0 +1,60 @@ +package org.yapp.apis.book.dto.response + +import org.yapp.infra.external.aladin.response.AladinSearchResponse +import org.yapp.infra.external.aladin.response.BookItem + +data class BookSearchResponse private constructor( + val version: String?, + val title: String?, + val link: String?, + val pubDate: String?, + val totalResults: Int?, + val startIndex: Int?, + val itemsPerPage: Int?, + val query: String?, + val searchCategoryId: Int?, + val searchCategoryName: String?, + val books: List +) { + companion object { + fun from(response: AladinSearchResponse): BookSearchResponse { + val books = response.item?.mapNotNull { BookSummary.fromAladinItem(it) } ?: emptyList() + return BookSearchResponse( + version = response.version, + title = response.title, + link = response.link, + pubDate = response.pubDate, + totalResults = response.totalResults, + startIndex = response.startIndex, + itemsPerPage = response.itemsPerPage, + query = response.query, + searchCategoryId = response.searchCategoryId, + searchCategoryName = response.searchCategoryName, + books = books + ) + } + } + + data class BookSummary private constructor( + val isbn: String, + val title: String, + val author: String?, + val publisher: String?, + val coverImageUrl: String?, + ) { + companion object { + private val unknownTitle = "제목없음" + + fun fromAladinItem(item: BookItem): BookSummary? { + val isbn = item.isbn ?: item.isbn13 ?: return null + return BookSummary( + isbn = isbn, + title = item.title ?: unknownTitle, + author = item.author, + publisher = item.publisher, + coverImageUrl = item.cover + ) + } + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/helper/AladinApiHelper.kt b/apis/src/main/kotlin/org/yapp/apis/book/helper/AladinApiHelper.kt new file mode 100644 index 00000000..c765003c --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/helper/AladinApiHelper.kt @@ -0,0 +1,46 @@ +package org.yapp.infra.external.aladin.helper + +import mu.KotlinLogging +import org.yapp.globalutils.annotation.Helper +import org.yapp.infra.external.aladin.AladinApi +import org.yapp.infra.external.aladin.dto.AladinBookLookupRequest // Import Aladin DTOs +import org.yapp.infra.external.aladin.dto.AladinBookSearchRequest // Import Aladin DTOs +import org.yapp.infra.external.aladin.response.AladinBookDetailResponse +import org.yapp.infra.external.aladin.response.AladinSearchResponse + + +@Helper +class AladinApiHelper( + private val aladinApi: AladinApi +) { + private val log = KotlinLogging.logger {} + + fun searchBooks(request: AladinBookSearchRequest): AladinSearchResponse { + return aladinApi.searchBooks(request) // Pass the DTO directly to AladinApi + .onSuccess { response -> + log.info("Aladin search successful for query: '${request.query}', total results: ${response.totalResults}") + } + .getOrElse { exception -> + log.error("Failed to call Aladin search API for request: '$request'", exception) + // TODO: 특정 비즈니스 예외로 맵핑하거나, 공통 예외 처리 계층에서 처리하도록 리팩토링 + throw IllegalStateException( + "Failed to retrieve search results from Aladin API: ${exception.message}", + exception + ) + } + } + + fun lookupBook(request: AladinBookLookupRequest): AladinBookDetailResponse { + return aladinApi.lookupBook(request) // Pass the DTO directly to AladinApi + .onSuccess { response -> + log.info("Aladin lookup successful for itemId: '${request.itemId}', title: ${response.item?.firstOrNull()?.title}") + } + .getOrElse { exception -> + log.error("Failed to call Aladin lookup API for request: '$request'", exception) + throw IllegalStateException( + "Failed to retrieve book details from Aladin API: ${exception.message}", + exception + ) + } + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/service/AladinBookQueryService.kt b/apis/src/main/kotlin/org/yapp/apis/book/service/AladinBookQueryService.kt new file mode 100644 index 00000000..76372795 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/service/AladinBookQueryService.kt @@ -0,0 +1,32 @@ +package org.yapp.apis.book.service + +import mu.KotlinLogging +import org.springframework.stereotype.Service +import org.yapp.apis.book.dto.request.BookDetailRequest +import org.yapp.apis.book.dto.request.BookSearchRequest +import org.yapp.apis.book.dto.response.BookDetailResponse +import org.yapp.apis.book.dto.response.BookSearchResponse +import org.yapp.infra.external.aladin.helper.AladinApiHelper +import org.yapp.infra.external.aladin.response.AladinBookDetailResponse +import org.yapp.infra.external.aladin.response.AladinSearchResponse + +@Service +class AladinBookQueryService( + private val aladinApiHelper: AladinApiHelper, +) { + private val log = KotlinLogging.logger {} + + fun searchBooks(request: BookSearchRequest): BookSearchResponse { + log.info("Service - Converting BookSearchRequest to AladinBookSearchRequest and calling Aladin API for book search.") + val aladinSearchRequest = request.toAladinRequest() + val response: AladinSearchResponse = aladinApiHelper.searchBooks(aladinSearchRequest) + return BookSearchResponse.from(response) + } + + fun lookupBook(request: BookDetailRequest): BookDetailResponse { + log.info("Service - Converting BookDetailRequest to AladinBookLookupRequest and calling Aladin API for book detail lookup.") + val aladinLookupRequest = request.toAladinRequest() + val aladinResponse: AladinBookDetailResponse = aladinApiHelper.lookupBook(aladinLookupRequest) + return BookDetailResponse.from(aladinResponse) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt b/apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt new file mode 100644 index 00000000..985cec8f --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/book/usecase/BookUseCase.kt @@ -0,0 +1,23 @@ +package org.yapp.apis.book.usecase + +import org.springframework.transaction.annotation.Transactional +import org.yapp.apis.book.dto.request.BookDetailRequest // Import BookDetailRequest +import org.yapp.apis.book.dto.request.BookSearchRequest +import org.yapp.apis.book.dto.response.BookDetailResponse +import org.yapp.apis.book.dto.response.BookSearchResponse +import org.yapp.apis.book.service.AladinBookQueryService +import org.yapp.globalutils.annotation.UseCase + +@UseCase +@Transactional(readOnly = true) +class BookUseCase( + private val aladinBookQueryService: AladinBookQueryService +) { + fun searchBooks(request: BookSearchRequest): BookSearchResponse { + return aladinBookQueryService.searchBooks(request) + } + + fun getBookDetail(request: BookDetailRequest): BookDetailResponse { + return aladinBookQueryService.lookupBook(request) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt index fffd3f14..d6d5242e 100644 --- a/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt +++ b/apis/src/main/kotlin/org/yapp/apis/config/InfraConfig.kt @@ -10,7 +10,7 @@ import org.yapp.infra.InfraBaseConfigGroup InfraBaseConfigGroup.JPA, InfraBaseConfigGroup.ASYNC, InfraBaseConfigGroup.REDIS, - InfraBaseConfigGroup.OAUTH + InfraBaseConfigGroup.REST_CLIENT ] ) class InfraConfig diff --git a/apis/src/main/kotlin/org/yapp/apis/config/RestTemplateConfig.kt b/apis/src/main/kotlin/org/yapp/apis/config/RestTemplateConfig.kt deleted file mode 100644 index 23ad3c3c..00000000 --- a/apis/src/main/kotlin/org/yapp/apis/config/RestTemplateConfig.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.yapp.apis.config - -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.web.client.RestTemplate - -/** - * Configuration for RestTemplate. - */ -@Configuration -class RestTemplateConfig { - - @Bean - fun restTemplate(): RestTemplate { - return RestTemplate() - } -} \ No newline at end of file diff --git a/apis/src/main/resources/application.yml b/apis/src/main/resources/application.yml index 83770259..3c8257e4 100644 --- a/apis/src/main/resources/application.yml +++ b/apis/src/main/resources/application.yml @@ -9,10 +9,12 @@ spring: - persistence - jwt - redis + - external prod: - persistence - jwt - redis + - external test: - persistence - jwt diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index ccde03b7..bcb7afba 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -40,8 +40,11 @@ object Dependencies { const val GENERATOR = "com.github.f4b6a3:uuid-creator:5.3.2" } - object Feign { - const val STARTER_OPENFEIGN = "org.springframework.cloud:spring-cloud-starter-openfeign" + object RestClient { + private const val HTTP_CLIENT5_VERSION = "5.2.1" + + const val HTTP_CLIENT5 = "org.apache.httpcomponents.client5:httpclient5:$HTTP_CLIENT5_VERSION" + const val HTTP_CORE5 = "org.apache.httpcomponents.core5:httpcore5:$HTTP_CLIENT5_VERSION" } object TestContainers { diff --git a/domain/src/main/kotlin/org/yapp/domain/book/Book.kt b/domain/src/main/kotlin/org/yapp/domain/book/Book.kt new file mode 100644 index 00000000..827ade71 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/book/Book.kt @@ -0,0 +1,81 @@ +package org.yapp.domain.book + +import java.time.LocalDateTime // Import LocalDateTime + +/** + * Represents a book in the domain model. + */ +data class Book private constructor( + val isbn: String, + val title: String, + val author: String?, + val publisher: String?, + val publicationYear: Int?, + val coverImageUrl: String?, + val description: String?, + val createdAt: LocalDateTime, // Added createdAt + val updatedAt: LocalDateTime, // Added updatedAt + val deletedAt: LocalDateTime? = null // Added deletedAt +) { + fun restore(): Book { + require(this.isDeleted()) { "Book is already active" } + return this.copy( + deletedAt = null, + updatedAt = LocalDateTime.now() + ) + } + + fun isDeleted(): Boolean = deletedAt != null + + companion object { + fun create( + isbn: String, + title: String, + author: String? = null, + publisher: String? = null, + publicationYear: Int? = null, + coverImageUrl: String? = null, + description: String? = null + ): Book { + val now = LocalDateTime.now() + return Book( + isbn = isbn, + title = title, + author = author, + publisher = publisher, + publicationYear = publicationYear, + coverImageUrl = coverImageUrl, + description = description, + createdAt = now, + updatedAt = now, + deletedAt = null + ) + } + + fun reconstruct( + isbn: String, + title: String, + author: String?, + publisher: String?, + publicationYear: Int?, + coverImageUrl: String?, + description: String?, + createdAt: LocalDateTime, + updatedAt: LocalDateTime, + deletedAt: LocalDateTime? = null + ): Book { + return Book( + isbn = isbn, + title = title, + author = author, + publisher = publisher, + publicationYear = publicationYear, + coverImageUrl = coverImageUrl, + description = description, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt + ) + } + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/book/BookRepository.kt b/domain/src/main/kotlin/org/yapp/domain/book/BookRepository.kt new file mode 100644 index 00000000..e3c0003a --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/book/BookRepository.kt @@ -0,0 +1,11 @@ +package org.yapp.domain.book + +/** + * Repository interface for Book domain model. + */ +interface BookRepository { + + fun findByIsbn(isbn: String): Book? + + fun save(book: Book): Book +} diff --git a/gateway/src/main/kotlin/org/yapp/gateway/config/SecurityConfig.kt b/gateway/src/main/kotlin/org/yapp/gateway/config/SecurityConfig.kt index b6e0ccc6..2b8fe508 100644 --- a/gateway/src/main/kotlin/org/yapp/gateway/config/SecurityConfig.kt +++ b/gateway/src/main/kotlin/org/yapp/gateway/config/SecurityConfig.kt @@ -18,7 +18,7 @@ import org.yapp.gateway.jwt.JwtTokenProvider class SecurityConfig( private val jwtTokenProvider: JwtTokenProvider ) { - + @Bean fun jwtAuthenticationFilter(): JwtAuthenticationFilter { return JwtAuthenticationFilter(jwtTokenProvider) @@ -33,6 +33,8 @@ class SecurityConfig( .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } .authorizeHttpRequests { it.requestMatchers("/api/v1/auth/**").permitAll() + it.requestMatchers("/api/v1/books/**").permitAll() + it.requestMatchers("/api/v1/health").permitAll() it.requestMatchers("/actuator/**").permitAll() it.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() it.requestMatchers("/kakao-login.html/**").permitAll() diff --git a/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt b/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt index 9182c39c..22a5f68d 100644 --- a/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt +++ b/global-utils/src/main/kotlin/org/yapp/globalutils/exception/CommonErrorCode.kt @@ -16,7 +16,9 @@ enum class CommonErrorCode( BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_003", "BAD REQUEST"), INVALID_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_004", "INVALID REQUEST"), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_005", "INTERNAL SERVER ERROR"), - MALFORMED_JSON(HttpStatus.BAD_REQUEST, "COMMON_006", "MALFORMED JSON"); + MALFORMED_JSON(HttpStatus.BAD_REQUEST, "COMMON_006", "MALFORMED JSON"), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "COMMON_007", "METHOD NOT ALLOWED"); + override fun getHttpStatus(): HttpStatus = httpStatus override fun getCode(): String = code diff --git a/global-utils/src/main/kotlin/org/yapp/globalutils/exception/GlobalExceptionHandler.kt b/global-utils/src/main/kotlin/org/yapp/globalutils/exception/GlobalExceptionHandler.kt index 39cb4fa1..95ffb25e 100644 --- a/global-utils/src/main/kotlin/org/yapp/globalutils/exception/GlobalExceptionHandler.kt +++ b/global-utils/src/main/kotlin/org/yapp/globalutils/exception/GlobalExceptionHandler.kt @@ -5,6 +5,7 @@ import mu.KotlinLogging import org.springframework.http.ResponseEntity import org.springframework.http.converter.HttpMessageNotReadableException import org.springframework.validation.BindException +import org.springframework.web.HttpRequestMethodNotSupportedException import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice @@ -59,6 +60,24 @@ class GlobalExceptionHandler { return ResponseEntity(error, errorCode.getHttpStatus()) } + /** + * 잘못된 인자 전달 (IllegalArgumentException) 처리 + */ + @ExceptionHandler(IllegalArgumentException::class) + protected fun handleIllegalArgumentException(ex: IllegalArgumentException): ResponseEntity { + val commonErrorCode = CommonErrorCode.INVALID_REQUEST + + log.warn { "Illegal argument: ${ex.message}" } + + val error = ErrorResponse.builder() + .status(commonErrorCode.getHttpStatus().value()) + .message(ex.message.orEmpty()) + .code(commonErrorCode.getCode()) + .build() + + return ResponseEntity(error, commonErrorCode.getHttpStatus()) + } + /** * @Valid를 통한 요청 본문(@RequestBody) 검증 실패 처리 */ @@ -81,6 +100,24 @@ class GlobalExceptionHandler { return ResponseEntity(error, commonErrorCode.getHttpStatus()) } + /** + * 잘못된 HTTP 메서드로 인한 HttpRequestMethodNotSupportedException를 처리합니다. + */ + @ExceptionHandler(HttpRequestMethodNotSupportedException::class) + protected fun handleHttpRequestMethodNotSupportedException(ex: HttpRequestMethodNotSupportedException): ResponseEntity { + val commonErrorCode = CommonErrorCode.METHOD_NOT_ALLOWED + + log.warn { "HTTP method not supported: ${ex.method} for ${ex.supportedHttpMethods?.joinToString()}" } + + val error = ErrorResponse.builder() + .status(commonErrorCode.getHttpStatus().value()) + .message("HTTP method '${ex.method}' not supported for this endpoint. Supported methods: ${ex.supportedHttpMethods?.joinToString()}") + .code(commonErrorCode.getCode()) + .build() + + return ResponseEntity(error, commonErrorCode.getHttpStatus()) + } + /** * 메서드 파라미터 검증 실패(@RequestParam, @PathVariable 등) diff --git a/global-utils/src/main/kotlin/org/yapp/globalutils/util/RegexUtils.kt b/global-utils/src/main/kotlin/org/yapp/globalutils/util/RegexUtils.kt index 0c7253b6..eec3afda 100644 --- a/global-utils/src/main/kotlin/org/yapp/globalutils/util/RegexUtils.kt +++ b/global-utils/src/main/kotlin/org/yapp/globalutils/util/RegexUtils.kt @@ -1,12 +1,12 @@ package org.yapp.globalutils.util - object RegexUtils { val EMAIL_PATTERN = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") val PROFILE_IMAGE_URL_PATTERN = Regex("^https?://[a-zA-Z0-9.-]+(/.*)?$") + const val NOT_BLANK_AND_NOT_NULL_STRING_PATTERN = "^(?!null$|NULL$|\\s*$).+" // Removed the old ISBN pattern fun isValidEmail(email: String): Boolean { return email.matches(EMAIL_PATTERN) diff --git a/infra/build.gradle.kts b/infra/build.gradle.kts index ea339490..868fa00a 100644 --- a/infra/build.gradle.kts +++ b/infra/build.gradle.kts @@ -3,16 +3,17 @@ import org.springframework.boot.gradle.tasks.bundling.BootJar dependencies { implementation(project(Dependencies.Projects.GLOBAL_UTILS)) implementation(project(Dependencies.Projects.DOMAIN)) - implementation(Dependencies.Spring.BOOT_STARTER_WEB) - testImplementation(Dependencies.Spring.BOOT_STARTER_TEST) implementation(Dependencies.Spring.BOOT_STARTER_DATA_JPA) implementation(Dependencies.Spring.BOOT_STARTER_DATA_REDIS) + implementation(Dependencies.RestClient.HTTP_CLIENT5) + implementation(Dependencies.RestClient.HTTP_CORE5) + testImplementation(Dependencies.Spring.BOOT_STARTER_TEST) + implementation(Dependencies.Spring.KOTLIN_REFLECT) implementation(Dependencies.Database.MYSQL_CONNECTOR) - implementation(Dependencies.Feign.STARTER_OPENFEIGN) implementation(Dependencies.Flyway.MYSQL) diff --git a/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt b/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt index 8e58f66e..209551ac 100644 --- a/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt +++ b/infra/src/main/kotlin/org/yapp/infra/InfraBaseConfigGroup.kt @@ -1,9 +1,9 @@ package org.yapp.infra -import org.yapp.infra.config.external.oauth.FeignConfig +import org.yapp.infra.config.external.api.RestClientConfig +import org.yapp.infra.config.external.redis.RedisConfig import org.yapp.infra.config.internal.async.AsyncConfig import org.yapp.infra.config.internal.jpa.JpaConfig -import org.yapp.infra.config.external.redis.RedisConfig enum class InfraBaseConfigGroup( val configClass: Class @@ -11,5 +11,5 @@ enum class InfraBaseConfigGroup( ASYNC(AsyncConfig::class.java), JPA(JpaConfig::class.java), REDIS(RedisConfig::class.java), - OAUTH(FeignConfig::class.java) + REST_CLIENT(RestClientConfig::class.java) } diff --git a/infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt b/infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt new file mode 100644 index 00000000..3839cb7f --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/book/entity/BookEntity.kt @@ -0,0 +1,76 @@ +package org.yapp.infra.book.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.annotations.SQLDelete +import org.yapp.domain.book.Book +import org.yapp.domain.common.BaseTimeEntity +import java.sql.Types + +@Entity +@Table(name = "books") +@SQLDelete(sql = "UPDATE books SET deleted_at = NOW() WHERE isbn = ?") +class BookEntity private constructor( + @Id + @JdbcTypeCode(Types.VARCHAR) + @Column(length = 13, updatable = false, nullable = false) // ISBN/ISBN13 length + val isbn: String, + @Column(nullable = false, length = 255) + val title: String, + + @Column(length = 255) + val author: String? = null, + + @Column(length = 255) + val publisher: String? = null, + + @Column(name = "publication_year") + val publicationYear: Int? = null, + + @Column(name = "cover_image_url", length = 2048) + val coverImageUrl: String? = null, + + @Column(length = 2000) + val description: String? = null +) : BaseTimeEntity() { + + fun toDomain(): Book = Book.reconstruct( + isbn = isbn, + title = title, + author = author, + publisher = publisher, + publicationYear = publicationYear, + coverImageUrl = coverImageUrl, + description = description, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt + ) + + companion object { + fun fromDomain(book: Book): BookEntity = BookEntity( + isbn = book.isbn, + title = book.title, + author = book.author, + publisher = book.publisher, + publicationYear = book.publicationYear, + coverImageUrl = book.coverImageUrl, + description = book.description + ).apply { + this.createdAt = book.createdAt + this.updatedAt = book.updatedAt + this.deletedAt = book.deletedAt + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is BookEntity) return false + return isbn == other.isbn + } + + override fun hashCode(): Int = isbn.hashCode() +} diff --git a/infra/src/main/kotlin/org/yapp/infra/book/repository/JpaBookRepository.kt b/infra/src/main/kotlin/org/yapp/infra/book/repository/JpaBookRepository.kt new file mode 100644 index 00000000..827a7a3c --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/book/repository/JpaBookRepository.kt @@ -0,0 +1,12 @@ +package org.yapp.infra.book.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.yapp.infra.book.entity.BookEntity + +/** + * JPA repository for BookEntity. + */ +interface JpaBookRepository : JpaRepository { + + fun findByIsbn(isbn: String): BookEntity? +} diff --git a/infra/src/main/kotlin/org/yapp/infra/book/repository/impl/BookRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/book/repository/impl/BookRepositoryImpl.kt new file mode 100644 index 00000000..7062e205 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/book/repository/impl/BookRepositoryImpl.kt @@ -0,0 +1,23 @@ +package org.yapp.infra.book.repository.impl + +import org.springframework.stereotype.Repository +import org.yapp.domain.book.Book +import org.yapp.domain.book.BookRepository +import org.yapp.infra.book.entity.BookEntity +import org.yapp.infra.book.repository.JpaBookRepository + +@Repository +class BookRepositoryImpl( + private val jpaBookRepository: JpaBookRepository +) : BookRepository { + + override fun findByIsbn(isbn: String): Book? { + return jpaBookRepository.findByIsbn(isbn)?.toDomain() + } + + override fun save(book: Book): Book { + val bookEntity = BookEntity.fromDomain(book) + val savedEntity = jpaBookRepository.save(bookEntity) + return savedEntity.toDomain() + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/config/external/api/RestClientConfig.kt b/infra/src/main/kotlin/org/yapp/infra/config/external/api/RestClientConfig.kt new file mode 100644 index 00000000..0472c84c --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/config/external/api/RestClientConfig.kt @@ -0,0 +1,46 @@ +package org.yapp.infra.config.external.api + +import org.apache.hc.client5.http.config.RequestConfig +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient +import org.apache.hc.client5.http.impl.classic.HttpClients +import org.apache.hc.core5.util.Timeout +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory +import org.springframework.web.client.RestClient +import org.yapp.infra.InfraBaseConfig + +@Configuration + +class RestClientConfig : InfraBaseConfig { + + @Bean + @Primary + fun generalRestClient(): RestClient { + return createConfiguredRestClientBuilder().build() + } + + @Bean("aladinApiRestClient") + fun aladinRestClient(): RestClient { + return createConfiguredRestClientBuilder() + .baseUrl("http://www.aladin.co.kr/ttb/api") + .build() + } + + private fun createConfiguredRestClientBuilder(): RestClient.Builder { + val requestConfig = RequestConfig.custom() + .setConnectionRequestTimeout(Timeout.ofDays(5000)) + .setResponseTimeout(Timeout.ofDays(5000)) + .build() + + val httpClient: CloseableHttpClient = HttpClients.custom() + .setDefaultRequestConfig(requestConfig) + .build() + + val factory = HttpComponentsClientHttpRequestFactory(httpClient) + + return RestClient.builder() + .requestFactory(factory) + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/config/external/oauth/FeignConfig.kt b/infra/src/main/kotlin/org/yapp/infra/config/external/oauth/FeignConfig.kt deleted file mode 100644 index e33f4bf1..00000000 --- a/infra/src/main/kotlin/org/yapp/infra/config/external/oauth/FeignConfig.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.yapp.infra.config.external.oauth - -import org.springframework.cloud.openfeign.EnableFeignClients -import org.springframework.context.annotation.Configuration -import org.yapp.infra.InfraBaseConfig - -@Configuration -@EnableFeignClients(basePackages = ["org.yapp.infra.external.oauth"]) -class FeignConfig : InfraBaseConfig diff --git a/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinApi.kt new file mode 100644 index 00000000..1293a41d --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinApi.kt @@ -0,0 +1,29 @@ +package org.yapp.infra.external.aladin + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import org.yapp.infra.external.aladin.dto.AladinBookLookupRequest +import org.yapp.infra.external.aladin.dto.AladinBookSearchRequest +import org.yapp.infra.external.aladin.response.AladinBookDetailResponse +import org.yapp.infra.external.aladin.response.AladinSearchResponse + +@Component +class AladinApi( + private val aladinRestClient: AladinRestClient, + @Value("\${aladin.api.ttbkey:#{null}}") + private var ttbKey: String? = null +) { + fun searchBooks(request: AladinBookSearchRequest): Result { + return runCatching { + val aladinApiParams = request.toMap() + aladinRestClient.itemSearch(ttbKey, aladinApiParams) // Map으로 전달 + } + } + + fun lookupBook(request: AladinBookLookupRequest): Result { + return runCatching { + val aladinApiParams = request.toMap() + aladinRestClient.itemLookUp(ttbKey, aladinApiParams) // Map으로 전달 + } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinRestClient.kt b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinRestClient.kt new file mode 100644 index 00000000..e8e858f6 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/aladin/AladinRestClient.kt @@ -0,0 +1,63 @@ +package org.yapp.infra.external.aladin + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.springframework.web.util.UriComponentsBuilder +import org.yapp.infra.external.aladin.response.AladinBookDetailResponse +import org.yapp.infra.external.aladin.response.AladinSearchResponse + +@Component +class AladinRestClient( + @Qualifier("aladinApiRestClient") private val restClient: RestClient +) { + + private val client = restClient + + private val API_VERSION = "20131101" + private val DEFAULT_OUTPUT_FORMAT = "JS" + + private fun UriComponentsBuilder.addCommonQueryParams(params: Map) { + params.forEach { (key, value) -> + if (key == "OptResult" && value is List<*>) { + this.queryParam(key, value.joinToString(",")) + } else { + this.queryParam(key, value) + } + } + this.queryParam("output", DEFAULT_OUTPUT_FORMAT) + .queryParam("Version", API_VERSION) + } + + fun itemSearch( + ttbKey: String?, + params: Map + ): AladinSearchResponse { + val uriBuilder = UriComponentsBuilder.fromUriString("/ItemSearch.aspx") + .queryParam("ttbkey", ttbKey) + + uriBuilder.addCommonQueryParams(params) + + return client.get() + .uri(uriBuilder.build().toUriString()) + .retrieve() + .body(AladinSearchResponse::class.java) + ?: throw IllegalStateException("Aladin ItemSearch API 응답이 null 입니다.") + } + + fun itemLookUp( + ttbKey: String?, + params: Map = emptyMap() + ): AladinBookDetailResponse { + val uriBuilder = UriComponentsBuilder.fromUriString("/ItemLookUp.aspx") + .queryParam("ttbkey", ttbKey) + + uriBuilder.addCommonQueryParams(params) + + return client.get() + .uri(uriBuilder.build().toUriString()) + .retrieve() + .body(AladinBookDetailResponse::class.java) + ?: throw IllegalStateException("Aladin ItemLookUp API 응답이 null 입니다.") + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/aladin/request/AladinBookLookupRequest.kt b/infra/src/main/kotlin/org/yapp/infra/external/aladin/request/AladinBookLookupRequest.kt new file mode 100644 index 00000000..1c5d2052 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/aladin/request/AladinBookLookupRequest.kt @@ -0,0 +1,33 @@ +package org.yapp.infra.external.aladin.dto + +/** + * 알라딘 ItemLookUp API 호출을 위한 요청 DTO. + * 내부적으로 알라딘 API 파라미터 규칙에 맞게 변환하는 책임을 가집니다. + */ +data class AladinBookLookupRequest private constructor( // private constructor 유지 + val itemId: String, + val itemIdType: String, + val optResult: List? +) { + fun toMap(): Map { + val params = mutableMapOf() + params["ItemId"] = itemId + params["ItemIdType"] = itemIdType + optResult?.let { + if (it.isNotEmpty()) { + params["OptResult"] = it + } + } + return params + } + + companion object { + fun create( + itemId: String, + itemIdType: String, + optResult: List? = null + ): AladinBookLookupRequest { + return AladinBookLookupRequest(itemId, itemIdType, optResult) + } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/aladin/request/AladinBookSearchRequest.kt b/infra/src/main/kotlin/org/yapp/infra/external/aladin/request/AladinBookSearchRequest.kt new file mode 100644 index 00000000..32714ebc --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/aladin/request/AladinBookSearchRequest.kt @@ -0,0 +1,49 @@ +package org.yapp.infra.external.aladin.dto + +data class AladinBookSearchRequest private constructor( + val query: String, + val queryType: String?, + val searchTarget: String?, + val maxResults: Int?, + val start: Int?, + val sort: String?, + val cover: String?, + val categoryId: Int? +) { + fun toMap(): Map { + val params = mutableMapOf() + params["Query"] = query + queryType?.let { params["QueryType"] = it } + searchTarget?.let { params["SearchTarget"] = it } + maxResults?.let { params["MaxResults"] = it } + start?.let { params["Start"] = it } + sort?.let { params["Sort"] = it } + cover?.let { params["Cover"] = it } + categoryId?.let { params["CategoryId"] = it } + return params + } + + companion object { + fun create( + query: String, + queryType: String? = null, + searchTarget: String? = null, + maxResults: Int? = null, + start: Int? = null, + sort: String? = null, + cover: String? = null, + categoryId: Int? = null + ): AladinBookSearchRequest { + return AladinBookSearchRequest( + query = query, + queryType = queryType, + searchTarget = searchTarget, + maxResults = maxResults, + start = start, + sort = sort, + cover = cover, + categoryId = categoryId + ) + } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/aladin/response/AladinResponseBase.kt b/infra/src/main/kotlin/org/yapp/infra/external/aladin/response/AladinResponseBase.kt new file mode 100644 index 00000000..0603c34e --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/aladin/response/AladinResponseBase.kt @@ -0,0 +1,52 @@ +package org.yapp.infra.external.aladin.response + +import com.fasterxml.jackson.annotation.JsonProperty +import java.math.BigDecimal + +data class BookItem internal constructor( + @JsonProperty("title") val title: String?, + @JsonProperty("link") val link: String?, + @JsonProperty("author") val author: String?, + @JsonProperty("pubDate") val pubDate: String?, + @JsonProperty("description") val description: String?, + @JsonProperty("isbn") val isbn: String?, + @JsonProperty("isbn13") val isbn13: String?, + @JsonProperty("itemId") val itemId: Long?, + @JsonProperty("priceSales") val priceSales: BigDecimal?, + @JsonProperty("priceStandard") val priceStandard: BigDecimal?, + @JsonProperty("mallType") val mallType: String?, + @JsonProperty("stockStatus") val stockStatus: String? = null, + @JsonProperty("mileage") val mileage: Int?, + @JsonProperty("cover") val cover: String?, + @JsonProperty("categoryId") val categoryId: Int?, + @JsonProperty("categoryName") val categoryName: String?, + @JsonProperty("publisher") val publisher: String? +) + +data class AladinSearchResponse internal constructor( + @JsonProperty("version") val version: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("link") val link: String?, + @JsonProperty("pubDate") val pubDate: String?, + @JsonProperty("totalResults") val totalResults: Int?, + @JsonProperty("startIndex") val startIndex: Int?, + @JsonProperty("itemsPerPage") val itemsPerPage: Int?, + @JsonProperty("query") val query: String? = null, + @JsonProperty("searchCategoryId") val searchCategoryId: Int? = null, + @JsonProperty("searchCategoryName") val searchCategoryName: String? = null, + @JsonProperty("item") val item: List? = null +) + +data class AladinBookDetailResponse( + @JsonProperty("version") val version: String?, + @JsonProperty("title") val title: String?, + @JsonProperty("link") val link: String?, + @JsonProperty("pubDate") val pubDate: String?, + @JsonProperty("totalResults") val totalResults: Int?, + @JsonProperty("startIndex") val startIndex: Int?, + @JsonProperty("itemsPerPage") val itemsPerPage: Int?, + @JsonProperty("query") val query: String? = null, + @JsonProperty("searchCategoryId") val searchCategoryId: Int? = null, + @JsonProperty("searchCategoryName") val searchCategoryName: String? = null, + @JsonProperty("item") val item: List? = null +) diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt index 6facd021..a8206efc 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoApi.kt @@ -4,8 +4,8 @@ import org.springframework.stereotype.Component import org.yapp.infra.external.oauth.kakao.response.KakaoUserInfo @Component -class KakaoApi internal constructor( - private val kakaoFeignApi: KakaoFeignApi +class KakaoApi( + private val kakaoRestClient: KakaoRestClient ) { companion object { private const val BEARER_PREFIX = "Bearer " @@ -13,7 +13,7 @@ class KakaoApi internal constructor( fun fetchUserInfo(accessToken: String): Result { return runCatching { - val response = kakaoFeignApi.getUserInfo(BEARER_PREFIX + accessToken) + val response = kakaoRestClient.getUserInfo(BEARER_PREFIX + accessToken) KakaoUserInfo.from(response) } } diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoFeignApi.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoFeignApi.kt deleted file mode 100644 index 164f0908..00000000 --- a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoFeignApi.kt +++ /dev/null @@ -1,13 +0,0 @@ -package org.yapp.infra.external.oauth.kakao - -import org.springframework.cloud.openfeign.FeignClient -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestHeader -import org.yapp.infra.external.oauth.kakao.response.KakaoResponse - -@FeignClient(name = "kakao-api", url = "https://kapi.kakao.com") -internal interface KakaoFeignApi { - - @GetMapping("/v2/user/me") - fun getUserInfo(@RequestHeader("Authorization") bearerToken: String): KakaoResponse -} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoRestClient.kt b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoRestClient.kt new file mode 100644 index 00000000..05d33a49 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/external/oauth/kakao/KakaoRestClient.kt @@ -0,0 +1,23 @@ +package org.yapp.infra.external.oauth.kakao + +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.yapp.infra.external.oauth.kakao.response.KakaoResponse + +@Component +class KakaoRestClient( + builder: RestClient.Builder +) { + private val client = builder + .baseUrl("https://kapi.kakao.com") + .build() + + fun getUserInfo(bearerToken: String): KakaoResponse { + return client.get() + .uri("/v2/user/me") + .header("Authorization", bearerToken) + .retrieve() + .body(KakaoResponse::class.java) + ?: throw IllegalStateException("Kakao API 응답이 null 입니다.") + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/external/redis/repository/RefreshTokenRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/external/redis/repository/impl/RefreshTokenRepositoryImpl.kt similarity index 88% rename from infra/src/main/kotlin/org/yapp/infra/external/redis/repository/RefreshTokenRepositoryImpl.kt rename to infra/src/main/kotlin/org/yapp/infra/external/redis/repository/impl/RefreshTokenRepositoryImpl.kt index e4b886ce..d481ed34 100644 --- a/infra/src/main/kotlin/org/yapp/infra/external/redis/repository/RefreshTokenRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/external/redis/repository/impl/RefreshTokenRepositoryImpl.kt @@ -1,9 +1,10 @@ -package org.yapp.infra.external.redis.repository +package org.yapp.infra.external.redis.repository.impl import org.springframework.stereotype.Repository import org.yapp.domain.token.RefreshToken import org.yapp.domain.token.RefreshTokenRepository import org.yapp.infra.external.redis.entity.RefreshTokenEntity +import org.yapp.infra.external.redis.repository.JpaRefreshTokenRepository import java.util.* diff --git a/infra/src/main/kotlin/org/yapp/infra/user/repository/UserRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt similarity index 93% rename from infra/src/main/kotlin/org/yapp/infra/user/repository/UserRepositoryImpl.kt rename to infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt index d98a70c6..7854594c 100644 --- a/infra/src/main/kotlin/org/yapp/infra/user/repository/UserRepositoryImpl.kt +++ b/infra/src/main/kotlin/org/yapp/infra/user/repository/impl/UserRepositoryImpl.kt @@ -1,13 +1,13 @@ -package org.yapp.infra.user.repository +package org.yapp.infra.user.repository.impl import org.springframework.stereotype.Repository import org.yapp.domain.auth.ProviderType import org.yapp.domain.user.User import org.yapp.domain.user.UserRepository import org.yapp.infra.user.entity.UserEntity +import org.yapp.infra.user.repository.JpaUserRepository import java.util.* - @Repository class UserRepositoryImpl( private val jpaUserRepository: JpaUserRepository diff --git a/infra/src/main/resources/application-external.yml b/infra/src/main/resources/application-external.yml new file mode 100644 index 00000000..63c84a30 --- /dev/null +++ b/infra/src/main/resources/application-external.yml @@ -0,0 +1,3 @@ +aladin: + api: + ttbkey: ${ALADIN_API_KEY}