-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 알라딘 외부 API 도서검색 및 상세 조회 기능 #26
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
Changes from all commits
517ae17
f048cf5
cdb4ba8
c8782d9
d3b0c44
d91523a
3148203
a9ffd4f
629053b
6c4f147
b5856e1
2d22b33
35632d8
11121ff
f98a2d9
e2fdf05
62fe27a
c354dcb
edacec3
887d2a9
3ff9bc2
b76cae6
6313699
433c2f1
9bbebe6
26b7da8
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,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<BookSearchResponse> { | ||
| val response = bookUseCase.searchBooks(request) | ||
| return ResponseEntity.ok(response) | ||
| } | ||
|
|
||
| @GetMapping("/detail") | ||
| override fun getBookDetail( | ||
| @Valid @ModelAttribute request: BookDetailRequest | ||
| ): ResponseEntity<BookDetailResponse> { | ||
| val response = bookUseCase.getBookDetail(request) | ||
| return ResponseEntity.ok(response) | ||
| } | ||
| } | ||
|
Comment on lines
+16
to
+35
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. 🧹 Nitpick (assertive) 예외 처리 고려 제안 현재 controller에서는 성공 케이스만 처리하고 있습니다. UseCase나 하위 계층에서 발생할 수 있는 예외에 대한 처리를 고려해보세요. GlobalExceptionHandler에서 공통 예외 처리를 하고 있다면 현재 구조도 충분하지만, 비즈니스 로직 특화된 예외 처리가 필요한 경우 추가 고려가 필요합니다. 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 = "도서 검색 요청 객체. 다음 쿼리 파라미터를 포함합니다:<br>" + | ||
| "- `query` (필수): 검색어 <br>" + | ||
| "- `queryType` (선택): 검색어 타입 (예: Title, Author). 기본값은 All <br>" + | ||
| "- `maxResults` (선택): 한 페이지당 결과 개수 (1-50). 기본값 10 <br>" + | ||
| "- `start` (선택): 결과 시작 페이지. 기본값 1 <br>" + | ||
| "- `sort` (선택): 정렬 방식 (예: PublishTime, SalesPoint). 기본값 Accuracy <br>" + | ||
| "- `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<BookSearchResponse> | ||
|
|
||
|
|
||
| @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 = "도서 상세 조회 요청 객체. 다음 쿼리 파라미터를 포함합니다:<br>" + | ||
| "- `itemId` (필수): 조회할 도서의 고유 ID (ISBN, ISBN13, 알라딘 ItemId 등)<br>" + | ||
| "- `itemIdType` (선택): `itemId`의 타입 (ISBN, ISBN13, ItemId). 기본값은 ISBN입니다.<br>" + | ||
| "- `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<BookDetailResponse> | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String>? = null | ||
| ) { | ||
| fun toAladinRequest(): AladinBookLookupRequest { | ||
| return AladinBookLookupRequest.create( | ||
| itemId = this.itemId!!, | ||
|
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. 🛠️ Refactor suggestion 안전하지 않은 non-null assertion 사용
-itemId = this.itemId!!,
+itemId = this.itemId ?: throw IllegalArgumentException("Item ID is required"),또는 validation 통과를 보장하는 더 안전한 방법을 사용하는 것을 권장합니다. 🤖 Prompt for AI Agents |
||
| itemIdType = this.itemIdType ?: "ISBN", | ||
| optResult = this.optResult | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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!! | ||
|
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. 🛠️ Refactor suggestion 안전한 검증 방법으로 개선 필요
-fun validQuery(): String = query!!
+fun validQuery(): String = query ?: throw IllegalArgumentException("Query parameter is required")또는 validation 통과를 보장하는 안전한 방법을 사용하는 것을 권장합니다. 🤖 Prompt for AI Agents |
||
| 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 | ||
| ) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 ?: "", | ||||||
|
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. 🧹 Nitpick (assertive) ISBN 필드 우선순위 로직 개선 ISBN 필드에서 - isbn = bookItem.isbn ?: bookItem.isbn13 ?: "",
+ isbn = bookItem.isbn13 ?: bookItem.isbn ?: "",📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
Member
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. isbn13 좋습니다 ㅎㅎ 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. @hoonyworld 감사합니다! ISBN13이 더 표준적이고 범용적이라 좋은 선택이라고 생각합니다. 👍 ( ˶ᵔ ᵕ ᵔ˶ ) 🧠 Learnings usedIf you found this review helpful, would you consider giving us a shout-out on X? Thank you for using CodeRabbit! |
||||||
| 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 ?: "", | ||||||
| ) | ||||||
| } | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<BookSummary> | ||
|
Comment on lines
+6
to
+17
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. 🧹 Nitpick (assertive) nullable 필드가 많은 메타데이터 구조 검토 BookSearchResponse의 대부분 필드가 nullable로 정의되어 있습니다. 외부 API 응답의 불확실성을 고려한 설계로 보이지만, 일부 필드(totalResults, startIndex, itemsPerPage 등)는 검색 결과의 핵심 정보로서 non-null이 더 적절할 수 있습니다. 외부 API 응답에서 이러한 필드들이 항상 제공되는지 확인하고, 필요시 기본값을 제공하는 것을 고려해보세요. 🤖 Prompt for AI Agents |
||
| ) { | ||
| 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 | ||
|
Member
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. 해당 부분 위에서는 isbn13을 사용하셨는데, 해당 방식으로 처리하신 이유는 확장성 때문일까요?
Member
Author
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. 네네 맞아요! 아무래도 ISBN값을 키값으로 잡았는데 없을 경우를 대비해서 저렇게 잡았습니다 ~ |
||
| return BookSummary( | ||
| isbn = isbn, | ||
| title = item.title ?: unknownTitle, | ||
| author = item.author, | ||
| publisher = item.publisher, | ||
| coverImageUrl = item.cover | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
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.
🧹 Nitpick (assertive)
spring-boot-starter-test의존성 중복10행과 26행에 동일 의존성이 중복되어 있습니다. 하나만 남기고 제거해 주세요.
- testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)📝 Committable suggestion
🤖 Prompt for AI Agents
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.
@minwoo1999 요것도 확인부탁드려요!!
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.