Skip to content

Commit cb55e09

Browse files
authored
알라딘 외부 API 도서검색 및 상세 조회 기능 (#26)
* [BOOK-79] feat: RestClient 알라딘 API적용 (#18) * [BOOK-79] fix: IllegalArgumentException 500 공통에러로 떨어지는 문제 해결 (#15) * [BOOK-79] feat: apis - 알라딘 도서검색, 도서상세검색 Controlller (#15) * [BOOK-79] feat: apis - 알라딘 도서검색, 도서상세검색 DTO (#15) * [BOOK-79] feat: apis - 알라딘 도서검색, 도서상세검색 외부 API Helper (#15) * [BOOK-79] feat: apis - 알라딘 도서검색, 도서상세검색 UseCase (#15) * [BOOK-79] feat: apis - 알라딘 도서검색, 도서상세검색 Service (#15) * [BOOK-79] feat: apis - 알라딘 도서검색, 도서상세검색 외부 API Response 정의 (#15) * [BOOK-79] feat: gateway - 도서관련 security permitAll (#15) * [BOOK-79] feat: domain - domain model (#15) * [BOOK-79] feat: infra - domain entity 설계 (#15) * [BOOK-79] refactor: global-util - HttpRequestMethodNotSupportedException 추가 (#15) * [BOOK-79] chore: infra external.yml파일 알라딘 api key 세팅 (#15) * [BOOK-79] refactor: infra user impl 분리 (#15) * [BOOK-79] refactor: infra - 알라딘 책 가격 부동소수점 오류를 위한 BigDecimal (#15) * [BOOK-79] refactor: infra - 알라딘 외부 API용 DTO분리 (#15) * [BOOK-79] refactor: apis - external yml group 추가 (#15) * [BOOK-79] refactor: apis - 요청 DTO값 분리 및 알라딘 외부용 API 분리 (#15) * [BOOK-79] refactor: �admin - external yml group 추가 (#15) * [BOOK-79] fix: apis - inner dto class 이름변경 (#15) * [BOOK-79] feat: infra - BookRepository 기능개발 (#15) * [BOOK-79] feat: �domain - BookRepository 기능개발 (#15) * [BOOK-79] refactor: infra,apis RestClient 각 외부 APi별 분리 (#15) * [BOOK-79] �chore: infra,apis 필요없는 코드 삭제 (#15) * [BOOK-79] refactor: apis,global-utils validation 강화 (#15)
1 parent 7ae7db6 commit cb55e09

File tree

39 files changed

+996
-59
lines changed

39 files changed

+996
-59
lines changed

admin/src/main/resources/application.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ spring:
99
- persistence
1010
- jwt
1111
- redis
12+
- external
1213
prod:
1314
- persistence
1415
- jwt
1516
- redis
17+
- external
1618
test:
1719
- persistence
1820
- jwt

apis/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ dependencies {
77
implementation(project(Dependencies.Projects.GATEWAY))
88

99
implementation(Dependencies.Spring.BOOT_STARTER_WEB)
10-
testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
1110
implementation(Dependencies.Spring.BOOT_STARTER_DATA_JPA)
1211
implementation(Dependencies.Spring.BOOT_STARTER_SECURITY)
1312
implementation(Dependencies.Spring.BOOT_STARTER_VALIDATION)
@@ -22,11 +21,12 @@ dependencies {
2221
implementation(Dependencies.Swagger.SPRINGDOC_OPENAPI_STARTER_WEBMVC_UI)
2322

2423
implementation(Dependencies.Logging.KOTLIN_LOGGING)
25-
implementation(Dependencies.Feign.STARTER_OPENFEIGN)
2624

25+
testImplementation(Dependencies.Spring.BOOT_STARTER_TEST)
2726
testImplementation(Dependencies.TestContainers.MYSQL)
2827
testImplementation(Dependencies.TestContainers.JUNIT_JUPITER)
2928
testImplementation(Dependencies.TestContainers.REDIS)
29+
3030
}
3131

3232
tasks {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.yapp.apis.book.controller
2+
3+
import jakarta.validation.Valid
4+
import org.springframework.http.ResponseEntity
5+
import org.springframework.web.bind.annotation.GetMapping
6+
import org.springframework.web.bind.annotation.ModelAttribute
7+
import org.springframework.web.bind.annotation.RequestMapping
8+
import org.springframework.web.bind.annotation.RestController
9+
import org.yapp.apis.book.dto.request.BookDetailRequest
10+
import org.yapp.apis.book.dto.request.BookSearchRequest
11+
import org.yapp.apis.book.dto.response.BookDetailResponse
12+
import org.yapp.apis.book.dto.response.BookSearchResponse
13+
import org.yapp.apis.book.usecase.BookUseCase
14+
15+
16+
@RestController
17+
@RequestMapping("/api/v1/books")
18+
class BookController(
19+
private val bookUseCase: BookUseCase
20+
) : BookControllerApi {
21+
22+
@GetMapping("/search")
23+
override fun searchBooks(@Valid @ModelAttribute request: BookSearchRequest): ResponseEntity<BookSearchResponse> {
24+
val response = bookUseCase.searchBooks(request)
25+
return ResponseEntity.ok(response)
26+
}
27+
28+
@GetMapping("/detail")
29+
override fun getBookDetail(
30+
@Valid @ModelAttribute request: BookDetailRequest
31+
): ResponseEntity<BookDetailResponse> {
32+
val response = bookUseCase.getBookDetail(request)
33+
return ResponseEntity.ok(response)
34+
}
35+
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package org.yapp.apis.book.controller
2+
3+
import io.swagger.v3.oas.annotations.Operation
4+
import io.swagger.v3.oas.annotations.Parameter
5+
import io.swagger.v3.oas.annotations.media.Content
6+
import io.swagger.v3.oas.annotations.media.ExampleObject
7+
import io.swagger.v3.oas.annotations.media.Schema
8+
import io.swagger.v3.oas.annotations.responses.ApiResponse
9+
import io.swagger.v3.oas.annotations.responses.ApiResponses
10+
import io.swagger.v3.oas.annotations.tags.Tag
11+
import jakarta.validation.Valid
12+
import org.springframework.http.ResponseEntity
13+
import org.springframework.web.bind.annotation.GetMapping
14+
import org.springframework.web.bind.annotation.RequestMapping
15+
import org.yapp.apis.book.dto.request.BookDetailRequest
16+
import org.yapp.apis.book.dto.request.BookSearchRequest
17+
import org.yapp.apis.book.dto.response.BookDetailResponse
18+
import org.yapp.apis.book.dto.response.BookSearchResponse
19+
20+
/**
21+
* API interface for book controller.
22+
*/
23+
@Tag(name = "Books", description = "도서 정보를 조회하는 API")
24+
@RequestMapping("/api/v1/books")
25+
interface BookControllerApi {
26+
27+
@Operation(
28+
summary = "도서 검색",
29+
description = "키워드를 사용하여 알라딘 도서 정보를 검색합니다."
30+
)
31+
@ApiResponses(
32+
value = [
33+
ApiResponse(
34+
responseCode = "200",
35+
description = "성공적인 검색",
36+
content = [Content(schema = Schema(implementation = BookSearchResponse::class))]
37+
),
38+
ApiResponse(
39+
responseCode = "400",
40+
description = "잘못된 요청 파라미터"
41+
)
42+
]
43+
)
44+
@GetMapping("/search")
45+
fun searchBooks(
46+
@Valid
47+
@Parameter(
48+
description = "도서 검색 요청 객체. 다음 쿼리 파라미터를 포함합니다:<br>" +
49+
"- `query` (필수): 검색어 <br>" +
50+
"- `queryType` (선택): 검색어 타입 (예: Title, Author). 기본값은 All <br>" +
51+
"- `maxResults` (선택): 한 페이지당 결과 개수 (1-50). 기본값 10 <br>" +
52+
"- `start` (선택): 결과 시작 페이지. 기본값 1 <br>" +
53+
"- `sort` (선택): 정렬 방식 (예: PublishTime, SalesPoint). 기본값 Accuracy <br>" +
54+
"- `categoryId` (선택): 카테고리 ID",
55+
examples = [
56+
ExampleObject(name = "기본 검색", value = "http://localhost:8080/api/v1/books/search?query=코틀린"),
57+
ExampleObject(
58+
name = "상세 검색",
59+
value = "http://localhost:8080/api/v1/books/search?query=클린코드&queryType=Title&maxResults=10&sort=PublishTime"
60+
),
61+
ExampleObject(
62+
name = "카테고리 검색",
63+
value = "http://localhost:8080/api/v1/books/search?query=Spring&categoryId=170&start=2&maxResults=5"
64+
)
65+
]
66+
)
67+
request: BookSearchRequest
68+
): ResponseEntity<BookSearchResponse>
69+
70+
71+
@Operation(
72+
summary = "도서 상세 조회",
73+
description = "특정 도서의 상세 정보를 조회합니다. `itemId`는 쿼리 파라미터로 전달됩니다."
74+
)
75+
@ApiResponses(
76+
value = [
77+
ApiResponse(
78+
responseCode = "200",
79+
description = "성공적으로 도서 상세 정보를 조회했습니다.",
80+
content = [Content(schema = Schema(implementation = BookDetailResponse::class))]
81+
),
82+
ApiResponse(
83+
responseCode = "400",
84+
description = "잘못된 요청 파라미터 (예: 유효하지 않은 itemId 또는 itemIdType)"
85+
),
86+
ApiResponse(
87+
responseCode = "404",
88+
description = "해당하는 itemId를 가진 도서를 찾을 수 없습니다."
89+
)
90+
]
91+
)
92+
@GetMapping("/detail")
93+
fun getBookDetail(
94+
@Valid
95+
@Parameter(
96+
description = "도서 상세 조회 요청 객체. 다음 쿼리 파라미터를 포함합니다:<br>" +
97+
"- `itemId` (필수): 조회할 도서의 고유 ID (ISBN, ISBN13, 알라딘 ItemId 등)<br>" +
98+
"- `itemIdType` (선택): `itemId`의 타입 (ISBN, ISBN13, ItemId). 기본값은 ISBN입니다.<br>" +
99+
"- `optResult` (선택): 조회할 부가 정보 목록 (쉼표로 구분). 예시: `BookInfo,Toc,PreviewImg`",
100+
examples = [
101+
ExampleObject(
102+
name = "ISBN으로 상세 조회",
103+
value = "http://localhost:8080/api/v1/books/detail?itemId=9791162241684&itemIdType=ISBN13"
104+
),
105+
ExampleObject(
106+
name = "ISBN 및 부가 정보 포함",
107+
value = "http://localhost:8080/api/v1/books/detail?itemId=8994492040&itemIdType=ISBN&optResult=BookInfo,Toc"
108+
)
109+
]
110+
)
111+
request: BookDetailRequest
112+
): ResponseEntity<BookDetailResponse>
113+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package org.yapp.apis.book.dto.request
2+
3+
import jakarta.validation.constraints.NotBlank
4+
import jakarta.validation.constraints.Pattern // Pattern 어노테이션 추가
5+
import org.yapp.globalutils.util.RegexUtils
6+
import org.yapp.infra.external.aladin.dto.AladinBookLookupRequest
7+
8+
data class BookDetailRequest private constructor(
9+
@field:NotBlank(message = "아이템 ID는 필수입니다.")
10+
@field:Pattern(
11+
regexp = RegexUtils.NOT_BLANK_AND_NOT_NULL_STRING_PATTERN,
12+
message = "아이템 ID는 유효한 ISBN 형식이 아닙니다."
13+
)
14+
val itemId: String? = null,
15+
val itemIdType: String? = "ISBN",
16+
val optResult: List<String>? = null
17+
) {
18+
fun toAladinRequest(): AladinBookLookupRequest {
19+
return AladinBookLookupRequest.create(
20+
itemId = this.itemId!!,
21+
itemIdType = this.itemIdType ?: "ISBN",
22+
optResult = this.optResult
23+
)
24+
}
25+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.yapp.apis.book.dto.request
2+
3+
import org.yapp.infra.external.aladin.dto.AladinBookSearchRequest
4+
5+
6+
data class BookSearchRequest private constructor(
7+
val query: String? = null,
8+
val queryType: String? = null,
9+
val searchTarget: String? = null,
10+
val maxResults: Int? = null,
11+
val start: Int? = null,
12+
val sort: String? = null,
13+
val cover: String? = null,
14+
val categoryId: Int? = null
15+
) {
16+
17+
fun validQuery(): String = query!!
18+
fun toAladinRequest(): AladinBookSearchRequest {
19+
20+
return AladinBookSearchRequest.create(
21+
query = this.validQuery(),
22+
queryType = this.queryType,
23+
searchTarget = this.searchTarget,
24+
maxResults = this.maxResults,
25+
start = this.start,
26+
sort = this.sort,
27+
cover = this.cover,
28+
categoryId = this.categoryId
29+
)
30+
}
31+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.yapp.apis.book.dto.response
2+
3+
import org.yapp.infra.external.aladin.response.AladinBookDetailResponse
4+
import java.math.BigDecimal
5+
6+
/**
7+
* 단일 도서의 상세 정보를 나타내는 DTO.
8+
* 외부 API 응답 및 도메인 Book 객체로부터 변환됩니다.
9+
*/
10+
data class BookDetailResponse private constructor(
11+
val version: String?,
12+
val title: String,
13+
val link: String?,
14+
val author: String?,
15+
val pubDate: String?,
16+
val description: String?,
17+
val isbn: String?,
18+
val isbn13: String?,
19+
val itemId: Long?,
20+
val priceSales: BigDecimal?,
21+
val priceStandard: BigDecimal?,
22+
val mallType: String?,
23+
val stockStatus: String?,
24+
val mileage: Int?,
25+
val cover: String?,
26+
val categoryId: Int?,
27+
val categoryName: String?,
28+
val publisher: String?
29+
) {
30+
companion object {
31+
/**
32+
* AladinBookDetailResponse와 Book 도메인 객체로부터 BookDetailResponse를 생성합니다.
33+
*/
34+
fun from(response: AladinBookDetailResponse): BookDetailResponse {
35+
val bookItem = response.item?.firstOrNull()
36+
?: throw IllegalArgumentException("No book item found in detail response.")
37+
38+
return BookDetailResponse(
39+
version = response.version,
40+
title = bookItem.title ?: "",
41+
link = bookItem.link,
42+
author = bookItem.author ?: "",
43+
pubDate = bookItem.pubDate,
44+
description = bookItem.description ?: "",
45+
isbn = bookItem.isbn ?: bookItem.isbn13 ?: "",
46+
isbn13 = bookItem.isbn13,
47+
itemId = bookItem.itemId,
48+
priceSales = bookItem.priceSales,
49+
priceStandard = bookItem.priceStandard,
50+
mallType = bookItem.mallType,
51+
stockStatus = bookItem.stockStatus,
52+
mileage = bookItem.mileage,
53+
cover = bookItem.cover ?: "",
54+
categoryId = bookItem.categoryId,
55+
categoryName = bookItem.categoryName,
56+
publisher = bookItem.publisher ?: "",
57+
)
58+
}
59+
}
60+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package org.yapp.apis.book.dto.response
2+
3+
import org.yapp.infra.external.aladin.response.AladinSearchResponse
4+
import org.yapp.infra.external.aladin.response.BookItem
5+
6+
data class BookSearchResponse private constructor(
7+
val version: String?,
8+
val title: String?,
9+
val link: String?,
10+
val pubDate: String?,
11+
val totalResults: Int?,
12+
val startIndex: Int?,
13+
val itemsPerPage: Int?,
14+
val query: String?,
15+
val searchCategoryId: Int?,
16+
val searchCategoryName: String?,
17+
val books: List<BookSummary>
18+
) {
19+
companion object {
20+
fun from(response: AladinSearchResponse): BookSearchResponse {
21+
val books = response.item?.mapNotNull { BookSummary.fromAladinItem(it) } ?: emptyList()
22+
return BookSearchResponse(
23+
version = response.version,
24+
title = response.title,
25+
link = response.link,
26+
pubDate = response.pubDate,
27+
totalResults = response.totalResults,
28+
startIndex = response.startIndex,
29+
itemsPerPage = response.itemsPerPage,
30+
query = response.query,
31+
searchCategoryId = response.searchCategoryId,
32+
searchCategoryName = response.searchCategoryName,
33+
books = books
34+
)
35+
}
36+
}
37+
38+
data class BookSummary private constructor(
39+
val isbn: String,
40+
val title: String,
41+
val author: String?,
42+
val publisher: String?,
43+
val coverImageUrl: String?,
44+
) {
45+
companion object {
46+
private val unknownTitle = "제목없음"
47+
48+
fun fromAladinItem(item: BookItem): BookSummary? {
49+
val isbn = item.isbn ?: item.isbn13 ?: return null
50+
return BookSummary(
51+
isbn = isbn,
52+
title = item.title ?: unknownTitle,
53+
author = item.author,
54+
publisher = item.publisher,
55+
coverImageUrl = item.cover
56+
)
57+
}
58+
}
59+
}
60+
}

0 commit comments

Comments
 (0)