From 06726c0c268135be3e680c5dc9a965866737010b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=95=9C=EC=9D=8C?= Date: Thu, 10 Oct 2024 11:23:57 +0900 Subject: [PATCH] =?UTF-8?q?[BOOK-14]-=EB=84=A4=EC=9D=B4=EB=B2=84=20Open=20?= =?UTF-8?q?API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EB=8F=84=EC=84=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API=20=EA=B5=AC=ED=98=84=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [BOOK-14]-chore: 네이버 오픈 API 관련 설정 * [BOOK-14]-feat: 도서 엔티티 생성 * [BOOK-14]-feat: Open API 이용한 도서 검색 API * [BOOK-14]-feat: 유저 도서 간 다대다 매핑 * [BOOK-14]-refactor: API 호출 권한 검증 시 토큰 인증 필터 추가 --- build.gradle | 1 + .../booklog/common/config/SecurityConfig.java | 31 +++++++- .../domain/book/application/BookService.java | 74 +++++++++++++++++++ .../unit/booklog/domain/book/domain/Book.java | 60 +++++++++++++++ .../domain/book/domain/BookRepository.java | 4 + .../infrastructure/BookRepositoryImpl.java | 13 ++++ .../infrastructure/JpaBookRepository.java | 8 ++ .../book/presentation/BookController.java | 45 +++++++++++ .../response/BookPageResponse.java | 29 ++++++++ .../presentation/response/BookResponse.java | 50 +++++++++++++ .../presentation/response/FileResponse.java | 7 ++ .../unit/booklog/domain/user/domain/User.java | 15 +++- src/main/resources/application.yml | 8 +- 13 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 src/main/java/goorm/unit/booklog/domain/book/application/BookService.java create mode 100644 src/main/java/goorm/unit/booklog/domain/book/domain/Book.java create mode 100644 src/main/java/goorm/unit/booklog/domain/book/domain/BookRepository.java create mode 100644 src/main/java/goorm/unit/booklog/domain/book/infrastructure/BookRepositoryImpl.java create mode 100644 src/main/java/goorm/unit/booklog/domain/book/infrastructure/JpaBookRepository.java create mode 100644 src/main/java/goorm/unit/booklog/domain/book/presentation/BookController.java create mode 100644 src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookPageResponse.java create mode 100644 src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookResponse.java diff --git a/build.gradle b/build.gradle index 5fbca6f..a0a965d 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,7 @@ dependencies { implementation 'com.amazonaws:aws-java-sdk-s3:1.11.238' + implementation 'org.json:json:20210307' } tasks.named('test') { diff --git a/src/main/java/goorm/unit/booklog/common/config/SecurityConfig.java b/src/main/java/goorm/unit/booklog/common/config/SecurityConfig.java index 4bbf30d..722c043 100644 --- a/src/main/java/goorm/unit/booklog/common/config/SecurityConfig.java +++ b/src/main/java/goorm/unit/booklog/common/config/SecurityConfig.java @@ -5,6 +5,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -12,9 +15,13 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; +import goorm.unit.booklog.common.auth.application.UserDetailService; +import goorm.unit.booklog.common.auth.filter.TokenAuthenticationFilter; +import goorm.unit.booklog.common.auth.jwt.TokenProvider; import lombok.RequiredArgsConstructor; @Configuration @@ -22,6 +29,7 @@ @RequiredArgsConstructor @EnableMethodSecurity(securedEnabled = true) public class SecurityConfig { + private final TokenProvider tokenProvider; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -34,6 +42,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) + .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth .requestMatchers(SWAGGER_PATTERNS).permitAll() .requestMatchers(STATIC_RESOURCES_PATTERNS).permitAll() @@ -59,10 +68,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/error", "/favicon.ico", "/index.html", - "/api/v1/users/duplication", - "/api/v1/users/signup", - "/api/v1/auth/login" - + "/api/v1/users/signup", + "/api/v1/auth/login" + "/api/v1/users/duplication", }; CorsConfigurationSource corsConfigurationSource() { @@ -76,6 +84,21 @@ CorsConfigurationSource corsConfigurationSource() { }; } + @Bean + public TokenAuthenticationFilter tokenAuthenticationFilter() { + return new TokenAuthenticationFilter(tokenProvider); + } + + @Bean + public AuthenticationManager authenticationManager( + BCryptPasswordEncoder bCryptPasswordEncoder, + UserDetailService userDetailService + ){ + DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); + authProvider.setUserDetailsService(userDetailService); + authProvider.setPasswordEncoder(bCryptPasswordEncoder); + return new ProviderManager(authProvider); + } @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { diff --git a/src/main/java/goorm/unit/booklog/domain/book/application/BookService.java b/src/main/java/goorm/unit/booklog/domain/book/application/BookService.java new file mode 100644 index 0000000..20a481d --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/application/BookService.java @@ -0,0 +1,74 @@ +package goorm.unit.booklog.domain.book.application; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +import org.json.JSONArray; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.RequestEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import goorm.unit.booklog.common.response.PageableResponse; +import goorm.unit.booklog.domain.book.domain.BookRepository; +import goorm.unit.booklog.domain.book.presentation.response.BookPageResponse; +import goorm.unit.booklog.domain.book.presentation.response.BookResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class BookService { + private final BookRepository bookRepository; + + @Value("${naver.api.clientId}") + private String clientId; + + @Value("${naver.api.clientSecret}") + private String clientSecret; + + public BookPageResponse searchBooks(int page, int size, String keyword) { + URI uri = UriComponentsBuilder + .fromUriString("https://openapi.naver.com") + .path("/v1/search/book.json") + .queryParam("query", keyword) + .queryParam("display", size) + .queryParam("start", page) + .queryParam("sort", "sim") + .encode() + .build() + .toUri(); + + RequestEntity req = RequestEntity + .get(uri) + .header("X-Naver-Client-Id", clientId) + .header("X-Naver-Client-Secret", clientSecret) + .build(); + + RestTemplate restTemplate = new RestTemplate(); + String response = restTemplate.exchange(req, String.class).getBody(); + + JSONObject jsonResponse = new JSONObject(response); + JSONArray items = jsonResponse.getJSONArray("items"); + + List bookResponses = new ArrayList<>(); + for (int i = 0; i < items.length(); i++) { + JSONObject item = items.getJSONObject(i); + BookResponse bookResponse = BookResponse.of( + (long)(i + 1), + item.getString("title"), + item.getString("author"), + item.getString("description"), + item.getString("link") + ); + bookResponses.add(bookResponse); + } + + int total = jsonResponse.getInt("total"); + return BookPageResponse.of(bookResponses, PageableResponse.of(PageRequest.of(page, size), (long)total)); + + } +} diff --git a/src/main/java/goorm/unit/booklog/domain/book/domain/Book.java b/src/main/java/goorm/unit/booklog/domain/book/domain/Book.java new file mode 100644 index 0000000..eca743e --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/domain/Book.java @@ -0,0 +1,60 @@ +package goorm.unit.booklog.domain.book.domain; + +import static jakarta.persistence.GenerationType.IDENTITY; + +import java.util.ArrayList; +import java.util.List; + +import goorm.unit.booklog.common.domain.BaseTimeEntity; +import goorm.unit.booklog.domain.file.domain.File; +import goorm.unit.booklog.domain.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor(access= AccessLevel.PROTECTED) +public class Book extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + private String author; + + @Column(nullable = false, length = 1000) + private String description; + + @OneToOne + @JoinColumn(name = "file_id") + private File file; + + @ManyToMany(mappedBy = "books") + private List users = new ArrayList<>(); + + public static Book create(Long id, String title, String author, String description, File file) { + return Book.builder() + .id(id) + .title(title) + .author(author) + .description(description) + .file(file) + .build(); + } +} diff --git a/src/main/java/goorm/unit/booklog/domain/book/domain/BookRepository.java b/src/main/java/goorm/unit/booklog/domain/book/domain/BookRepository.java new file mode 100644 index 0000000..52f860d --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/domain/BookRepository.java @@ -0,0 +1,4 @@ +package goorm.unit.booklog.domain.book.domain; + +public interface BookRepository { +} diff --git a/src/main/java/goorm/unit/booklog/domain/book/infrastructure/BookRepositoryImpl.java b/src/main/java/goorm/unit/booklog/domain/book/infrastructure/BookRepositoryImpl.java new file mode 100644 index 0000000..9e9b828 --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/infrastructure/BookRepositoryImpl.java @@ -0,0 +1,13 @@ +package goorm.unit.booklog.domain.book.infrastructure; + +import org.springframework.stereotype.Repository; + +import goorm.unit.booklog.domain.book.domain.BookRepository; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class BookRepositoryImpl implements BookRepository { + private final JpaBookRepository jpaBookRepository; + +} diff --git a/src/main/java/goorm/unit/booklog/domain/book/infrastructure/JpaBookRepository.java b/src/main/java/goorm/unit/booklog/domain/book/infrastructure/JpaBookRepository.java new file mode 100644 index 0000000..eb3dd3d --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/infrastructure/JpaBookRepository.java @@ -0,0 +1,8 @@ +package goorm.unit.booklog.domain.book.infrastructure; + +import org.springframework.data.jpa.repository.JpaRepository; + +import goorm.unit.booklog.domain.book.domain.Book; + +public interface JpaBookRepository extends JpaRepository { +} diff --git a/src/main/java/goorm/unit/booklog/domain/book/presentation/BookController.java b/src/main/java/goorm/unit/booklog/domain/book/presentation/BookController.java new file mode 100644 index 0000000..d135699 --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/presentation/BookController.java @@ -0,0 +1,45 @@ +package goorm.unit.booklog.domain.book.presentation; + +import org.springframework.http.ResponseEntity; +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; + +import goorm.unit.booklog.domain.book.application.BookService; +import goorm.unit.booklog.domain.book.presentation.response.BookPageResponse; +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.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 lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/books") +@Tag(name = "Book", description = "도서 관련 api / 담당자 : 이한음") +public class BookController { + private final BookService bookService; + + @Operation(summary = "도서 검색", description = "네이버 도서 검색 Open API에서 키워드 기반으로 도서를 검색 합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "도서 검색 성공", + content = @Content(schema = @Schema(implementation = BookPageResponse.class)) + + ) + }) + @GetMapping("/search") + public ResponseEntity searchBooks( + @Parameter(description = "페이지 인덱스", example = "1", required = true) @RequestParam(value = "page", defaultValue = "1") int page, + @Parameter(description = "응답 개수", example = "10", required = true) @RequestParam(value = "size", defaultValue = "10") int size, + @Parameter(description = "키워드", example = "스프링", required = true) @RequestParam(value = "keyword") String keyword + ) { + BookPageResponse response = bookService.searchBooks(page, size, keyword); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookPageResponse.java b/src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookPageResponse.java new file mode 100644 index 0000000..a4295bf --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookPageResponse.java @@ -0,0 +1,29 @@ +package goorm.unit.booklog.domain.book.presentation.response; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import java.util.List; + +import goorm.unit.booklog.common.response.PageableResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record BookPageResponse( + @Schema( + description = "도서 목록", + example = "[{\"id\": 1, \"title\": \"스프링 부트와 AWS로 혼자 구현하는 웹 서비스\", \"author\": \"이한음\", \"description\": \"스프링 부트와 AWS로 혼자 구현하는 웹 서비스\", \"file\": {\"id\": 1, \"logicalName\": \"example.jpg\", \"physicalPath\": \"https://example-bucket.ncp.com/files/example.jpg\"}}]", + requiredMode = REQUIRED + ) + List contents, + + @Schema(description = "페이징 정보", requiredMode = REQUIRED) + PageableResponse pageable +) { + public static BookPageResponse of(List books, PageableResponse pageableResponse) { + return BookPageResponse.builder() + .contents(books) + .pageable(pageableResponse) + .build(); + } +} diff --git a/src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookResponse.java b/src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookResponse.java new file mode 100644 index 0000000..ca70039 --- /dev/null +++ b/src/main/java/goorm/unit/booklog/domain/book/presentation/response/BookResponse.java @@ -0,0 +1,50 @@ +package goorm.unit.booklog.domain.book.presentation.response; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import goorm.unit.booklog.domain.book.domain.Book; +import goorm.unit.booklog.domain.file.presentation.response.FileResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record BookResponse( + @Schema(description = "도서 ID", example = "1", requiredMode = REQUIRED) + Long id, + + @Schema(description = "도서 제목", example = "스프링 부트와 AWS로 혼자 구현하는 웹 서비스", requiredMode = REQUIRED) + String title, + + @Schema(description = "도서 저자", example = "이한음", requiredMode = NOT_REQUIRED) + String author, + + @Schema(description = "도서 설명", example = "스프링 부트와 AWS로 혼자 구현하는 웹 서비스", requiredMode = NOT_REQUIRED) + String description, + + @Schema( + description = "파일 정보", + example = "{\"id\": 1, \"logicalName\": \"example.jpg\", \"physicalPath\": \"https://example-bucket.ncp.com/files/example.jpg\"}", + requiredMode = NOT_REQUIRED) + FileResponse file +) { + public static BookResponse from(Book book) { + return BookResponse.builder() + .id(book.getId()) + .title(book.getTitle()) + .author(book.getAuthor()) + .description(book.getDescription()) + .file(FileResponse.from(book.getFile())) + .build(); + } + + public static BookResponse of (Long id, String title, String author, String description, String physicalPath) { + return BookResponse.builder() + .id(id) + .title(title) + .author(author) + .description(description) + .file(FileResponse.of(title, physicalPath)) + .build(); + } +} diff --git a/src/main/java/goorm/unit/booklog/domain/file/presentation/response/FileResponse.java b/src/main/java/goorm/unit/booklog/domain/file/presentation/response/FileResponse.java index b3fbec2..2b21238 100644 --- a/src/main/java/goorm/unit/booklog/domain/file/presentation/response/FileResponse.java +++ b/src/main/java/goorm/unit/booklog/domain/file/presentation/response/FileResponse.java @@ -33,4 +33,11 @@ public static FileResponse from(File file) { .physicalPath(endpoint + "/" + bucketName + "/" + file.getPhysicalPath()) .build(); } + + public static FileResponse of(String logicalName, String physicalPath) { + return FileResponse.builder() + .logicalName(logicalName + " 대표 이미지") + .physicalPath(physicalPath) + .build(); + } } diff --git a/src/main/java/goorm/unit/booklog/domain/user/domain/User.java b/src/main/java/goorm/unit/booklog/domain/user/domain/User.java index 22f8697..a410b0d 100644 --- a/src/main/java/goorm/unit/booklog/domain/user/domain/User.java +++ b/src/main/java/goorm/unit/booklog/domain/user/domain/User.java @@ -1,9 +1,15 @@ package goorm.unit.booklog.domain.user.domain; +import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.List; import goorm.unit.booklog.common.domain.BaseTimeEntity; +import goorm.unit.booklog.domain.book.domain.Book; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.JoinTable; +import jakarta.persistence.ManyToMany; import lombok.*; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -21,7 +27,6 @@ @Table(name="\"user\"") @AllArgsConstructor @NoArgsConstructor(access= AccessLevel.PROTECTED) - public class User extends BaseTimeEntity implements UserDetails { @Id @@ -34,6 +39,14 @@ public class User extends BaseTimeEntity implements UserDetails { @Column(nullable=false) private String password; + @ManyToMany + @JoinTable( + name = "user_books", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "book_id") + ) + private List books = new ArrayList<>(); + public static User create(String id, String name, String password) { return User.builder() .id(id) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aee30ee..9ae22ee 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -31,7 +31,6 @@ springdoc: operations-sorter: alpha tags-sorter: alpha - jwt: issuer: ${JWT_ISSUER} secret_key: ${JWT_SECRET_KEY} @@ -50,3 +49,10 @@ cloud: stack: auto: false +naver: + api: + clientId: ${NAVER_CLIENT_ID} + clientSecret: ${NAVER_CLIENT_SECRET} + + +