궤도

[Spring] [면적면적[4]] 검색 및 책 추가 API 만들기(네이버, 알라딘 API 사용) 본문

💻 현생/📕 면적면적(스프링 실습)

[Spring] [면적면적[4]] 검색 및 책 추가 API 만들기(네이버, 알라딘 API 사용)

영이오 2021. 7. 27. 19:49

북적북적의 백엔드를 클론하고 있다.

 

Github

 

build.gradle

plugins {
	id 'org.springframework.boot' version '2.5.2'
	id 'io.spring.dependency-management' version '1.0.11.RELEASE'
	id 'java'
}

group = 'jpa'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	all {
		exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.boot:spring-boot-starter-log4j2'
	implementation 'io.jsonwebtoken:jjwt:0.9.1'
	implementation 'org.projectlombok:lombok:1.18.18'
	runtimeOnly 'mysql:mysql-connector-java'
	implementation 'com.googlecode.json-simple:json-simple:1.1.1'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testImplementation 'org.springframework.security:spring-security-test'
}

test {
	useJUnitPlatform()
}

 

 

 

src/main/resources/application.yml

aladin:
  url: http://www.aladin.co.kr/ttb/api/ItemLookUp.aspx?ttbkey=비밀키&itemIdType=ISBN13&output=js&Version=20131101&ItemId={isbn}
naver:
  id: 아이디
  secret: 비번
secret:
  key: 비번
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3305/데베?serverTimezone=UTC&characterEncoding=UTF-8
    username: root
    password: 비번
  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false
    hibernate:
      ddl-auto: none
    properties:
      hibernate:
        format_sql: true
        show-sql: true
logging:
  level:
    org:
      hibernate:
        type:
          descriptor:
            sql: trace
server:
  servlet:
    encoding:
      force-response: true

요구사항 정리

 

책 검색

[Request] {keyword, start, GET}

1. 입력값 유효성 확인

 

[Response] {searchResultCount, Items, HttpStatus.OK}

 

책 상세 검색

[Request] {isbn, GET}

1. 입력값 유효성 확인

 

[Response] {item, HttpStatus.OK}

 

책 저장

[Request] {item, POST}

1. 입력값 유효성 확인

2. 중복 저장 여부 확인

3. 캐릭터 획득 여부 확인

 

[Response] {character, HttpStatus.OK} / {HttpStatus.NO_CONTENT}

 

책 중복 저장은 엄밀히 말하면 Update이니 이번엔 중복 저장시 에러만 던지도록 하겠다.


책 검색

 

1. 책 검색시엔 검색 키워드와 검색 시작 인덱스가 필요하다.

2. 한번 검색시 20개씩 준다.

 

도서 API 비교글

여기서 여러 API를 비교해보고 난 네이버 API와 알라딘 API를 사용하기로 했다.

기본적으론 네이버 API고, 책의 쪽수를 구할 때만 알라딘 API를 사용할 것이다.

 

일단 사용할 네이버 API를 postman으로 확인해보자.

 

결과 화면을 봤을 때 필요한 것은 다음과 같다.

검색 결과의 총 개수

결과로 나온 각 책들의 {제목, 작가, 소개글, 커버 이미지, isbn}

 

결과 화면에 isbn이 없는데 이건 왜 주는가 싶을 것이다. isbn은 상세검색에 사용할 pk와 같은 것이므로 넣는다.

 

src/main/java/jpa/myunjuk/module/model/dto/search/SearchDto.java

package jpa.myunjuk.module.model.dto.search;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SearchDto {

    public Integer total;
    List<Items> items = new ArrayList<>();

    static class Items{
        public String title;
        public String image;
        public String author;
        public String isbn;
        public String description;
    }
}

네이버 API의 스펙을 보면서 검색 결과를 담을 dto를 만들었다.

 

src/main/java/jpa/myunjuk/module/service/SearchService.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.infra.exception.DuplicateException;
import jpa.myunjuk.infra.exception.InvalidReqBodyException;
import jpa.myunjuk.infra.exception.InvalidReqParamException;
import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.BookStatus;
import jpa.myunjuk.module.model.domain.Characters;
import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.dto.search.*;
import jpa.myunjuk.module.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SearchService {

    @Value("${naver.id}")
    private String id;

    @Value("${naver.secret}")
    private String secret;

    @Value("${aladin.url}")
    private String pageUrl;
    private final String SEARCH_URL = "https://openapi.naver.com/v1/search/book.json?display=20";
    private final String DETAIL_URL = "https://openapi.naver.com/v1/search/book_adv.json";

    private final BookRepository bookRepository;
    private final CharactersService charactersService;

    /**
     * search
     *
     * @param keyword
     * @param start
     * @return SearchDto
     */
    public SearchDto search(String keyword, int start) {
        RestTemplate restTemplate = new RestTemplate();
        HttpEntity<String> httpEntity = getHttpEntity();
        URI targetUrl = UriComponentsBuilder
                .fromUriString(SEARCH_URL)
                .queryParam("query", keyword)
                .queryParam("start", start)
                .build()
                .encode(StandardCharsets.UTF_8)
                .toUri();
        return restTemplate.exchange(targetUrl, HttpMethod.GET, httpEntity, SearchDto.class).getBody();
    }

    private HttpEntity<String> getHttpEntity() { //헤더에 인증 정보 추가
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("X-Naver-Client-Id", id);
        httpHeaders.set("X-Naver-Client-Secret", secret);
        return new HttpEntity<>(httpHeaders);
    }
}

검색 서비스 코드이다.

 

    private HttpEntity<String> getHttpEntity() { //헤더에 인증 정보 추가
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("X-Naver-Client-Id", id);
        httpHeaders.set("X-Naver-Client-Secret", secret);
        return new HttpEntity<>(httpHeaders);
    }

이따 할 상세검색에서도 헤더정보가 필요하니 헤더에 인증 정보를 추가한 메소드를 따로 뺐다.

 

    /**
     * search
     *
     * @param keyword
     * @param start
     * @return SearchDto
     */
    public SearchDto search(String keyword, int start) {
        RestTemplate restTemplate = new RestTemplate();
        HttpEntity<String> httpEntity = getHttpEntity();
        URI targetUrl = UriComponentsBuilder
                .fromUriString(SEARCH_URL)
                .queryParam("query", keyword)
                .queryParam("start", start)
                .build()
                .encode(StandardCharsets.UTF_8)
                .toUri();
        return restTemplate.exchange(targetUrl, HttpMethod.GET, httpEntity, SearchDto.class).getBody();
    }

dto를 잘 만들었다면 결과가 잘 담길 것이다.

 

테스트를 하기 전에 @RequestParam에 대한 검증을 해야 한다.

@RequestParam에 있는 param들은 기본값이 required라 없으면

MissingServletRequestParameterException가 발생한다.

 

src/main/java/jpa/myunjuk/infra/exception/GlobalExceptionHandler.java

package jpa.myunjuk.infra.exception;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import java.time.format.DateTimeParseException;
import java.util.HashMap;
import java.util.Map;

@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({
            DuplicateException.class,
            NoSuchDataException.class,
            InvalidReqParamException.class,
            InvalidReqBodyException.class})
    public ResponseEntity<?> handleRuntimeExceptions(final CustomRuntimeException e) {
        return ResponseEntity.badRequest().body(errorMsg(e.getName(), e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<?> handleValidationExceptions(MethodArgumentNotValidException e) {
        Map<String, String> errors = new HashMap<>();
        e.getBindingResult().getAllErrors()
                .forEach(c -> errors.put(((FieldError) c).getField(), c.getDefaultMessage()));
        return ResponseEntity.badRequest().body(errors);
    }

	//추가
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<?> handleReqParamExceptions(MissingServletRequestParameterException e) {
        return ResponseEntity.badRequest().body(errorMsg(e.getParameterName(), e.getMessage()));
    }

    private Map<String, String> errorMsg(String name, String msg) {
        Map<String, String> error = new HashMap<>();
        error.put(name, msg);
        return error;
    }
}

해당 에러를 핸들링해주자

 

src/test/java/jpa/myunjuk/module/controller/SearchControllerTest.java

package jpa.myunjuk.module.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;

import java.util.HashMap;
import java.util.Map;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Transactional
@SpringBootTest
@DisplayName(value = "Book Search Test")
@AutoConfigureMockMvc(addFilters = false)
class SearchControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @Autowired
    WebApplicationContext webApplicationContext;

    @BeforeEach
    public void init() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .addFilters(new CharacterEncodingFilter("UTF-8", true))
                .alwaysDo(print())
                .build();
    }

    @Test
    @DisplayName(value = "Search | Success")
    void searchSuccess() throws Exception {
        mockMvc.perform(get("/search")
                .contentType(MediaType.APPLICATION_JSON)
                .param("keyword", "해리포터")
                .param("start", "1"))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName(value = "Search | Fail : Missing keyword")
    void searchFailKeyword() throws Exception {
        Map<String, String> error = new HashMap<>();
        error.put("keyword", "Required request parameter 'keyword' for method parameter type String is not present");
        String response = objectMapper.writeValueAsString(error);

        mockMvc.perform(get("/search")
                .contentType(MediaType.APPLICATION_JSON)
                .param("start", "1"))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }

    @Test
    @DisplayName(value = "Search | Fail : Missing start")
    void searchFailStart() throws Exception {
        Map<String, String> error = new HashMap<>();
        error.put("start", "Required request parameter 'start' for method parameter type int is not present");
        String response = objectMapper.writeValueAsString(error);

        mockMvc.perform(get("/search")
                .contentType(MediaType.APPLICATION_JSON)
                .param("keyword", "해리포터"))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }
}

그냥 외부 api 사용하는 코드라 테스트는 간단하게 작성했다.

 

이미 상세 검색 테스트 코드도 작성한 상태에서 쓰는 글이라 이렇지만 아무튼 다 통과

 

src/main/java/jpa/myunjuk/module/controller/SearchController.java

package jpa.myunjuk.module.controller;

import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.dto.search.AddSearchDetailResDto;
import jpa.myunjuk.module.model.dto.search.SearchReqDto;
import jpa.myunjuk.module.service.SearchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Slf4j
@RestController
@RequestMapping("search")
@RequiredArgsConstructor
public class SearchController {

    private final SearchService searchService;

    /**
     * 책 검색
     * localhost:8080/search
     *
     * @param keyword
     * @param start
     * @return ResponseEntity
     */
    @GetMapping("")
    public ResponseEntity<?> search(
            @RequestParam String keyword,
            @RequestParam int start) {
        log.info("[Request] search");
        return new ResponseEntity<>(searchService.search(keyword, start), HttpStatus.OK);
    }
}

포스트맨으로 호출한 결과이다.

네이버 API를 바로 호출했을 때와 비교하면 내가 필요한 것만 잘 뽑아진 것을 볼 수 있다.


책 상세 검색

 

1. 책 상세 검색시엔 isbn이 필요하다.

2. isbn으로 검색한 결과는 당연히 있어야 하며(같은 API를 사용하기 때문), isbn 형식이 맞아야 한다.

3. 페이지 정보는 없을 수도 있다.

 

결과 화면을 봤을 때 필요한 것은 다음과 같다.

{제목, 커버 이미지, 작가, 책 소개글, 출판사, isbn, 페이지 수, 링크}

 

네이버 API는 페이지 수를 제공하지 않는다. 그래서 페이지 수를 찾는 작업에 알라딘 API를 사용할 것이다.

 

src/main/java/jpa/myunjuk/module/model/dto/search/SearchResDto.java

package jpa.myunjuk.module.model.dto.search;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SearchResDto {

    private String title;
    private String url;
    private String thumbnail;
    private String author;
    private String publisher;
    private String isbn;
    private String description;
    private Integer totPage;
}

결과로 반환할 dto를 만들었다. totPage가 없을 수도 있으니 Integer로 선언한다.

 

src/main/java/jpa/myunjuk/module/service/SearchService.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.infra.exception.DuplicateException;
import jpa.myunjuk.infra.exception.InvalidReqBodyException;
import jpa.myunjuk.infra.exception.InvalidReqParamException;
import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.BookStatus;
import jpa.myunjuk.module.model.domain.Characters;
import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.dto.search.*;
import jpa.myunjuk.module.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SearchService {

    @Value("${naver.id}")
    private String id;

    @Value("${naver.secret}")
    private String secret;

    @Value("${aladin.url}")
    private String pageUrl;
    private final String SEARCH_URL = "https://openapi.naver.com/v1/search/book.json?display=20";
    private final String DETAIL_URL = "https://openapi.naver.com/v1/search/book_adv.json";

    private final BookRepository bookRepository;
    private final CharactersService charactersService;

    /**
     * searchDetail
     *
     * @param isbn
     * @return SearchResDto
     */
    public SearchResDto searchDetail(String isbn) {
        List<SearchDetailDto.Items> items = searchInfo(isbn).getItems();
        if (searchInfo(isbn).getItems().isEmpty() || !Pattern.matches("^.{10}\\s.{13}$", isbn)) //검색 결과가 없거나, isbn 형식이 잘못됐을 때
            throw new InvalidReqParamException("isbn = " + isbn);

        SearchDetailDto.Items item = items.get(0);
        String[] str = isbn.split(" ");
        Integer page = pageInfo(str[1]); //13자리 isbn
        return SearchResDto.builder()
                .title(item.title)
                .url(item.link)
                .thumbnail(item.image)
                .author(item.author)
                .publisher(item.publisher)
                .isbn(item.isbn)
                .description(item.description)
                .totPage(page)
                .build();
    }

    private SearchDetailDto searchInfo(String isbn) {
        RestTemplate restTemplate = new RestTemplate();
        HttpEntity<String> httpEntity = getHttpEntity();
        URI targetUrl = UriComponentsBuilder
                .fromUriString(DETAIL_URL)
                .queryParam("d_isbn", isbn)
                .build()
                .encode(StandardCharsets.UTF_8)
                .toUri();
        return restTemplate.exchange(targetUrl, HttpMethod.GET, httpEntity, SearchDetailDto.class).getBody();
    }

    private Integer pageInfo(String isbn) {
        RestTemplate restTemplate = new RestTemplate();
        Map obj = restTemplate.getForObject(pageUrl, Map.class, isbn);
        Integer page = null;
        if (Objects.requireNonNull(obj).get("errorCode") == null) //페이지 정보가 있다면
            page = (Integer) ((HashMap) ((HashMap) ((List) obj.get("item")).get(0)).get("subInfo")).get("itemPage");
        return page;
    }

    private HttpEntity<String> getHttpEntity() { //헤더에 인증 정보 추가
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.set("X-Naver-Client-Id", id);
        httpHeaders.set("X-Naver-Client-Secret", secret);
        return new HttpEntity<>(httpHeaders);
    }
}

일단 입력받은 isbn에 대한 책 정보를 찾아온다.

 

    private SearchDetailDto searchInfo(String isbn) {
        RestTemplate restTemplate = new RestTemplate();
        HttpEntity<String> httpEntity = getHttpEntity();
        URI targetUrl = UriComponentsBuilder
                .fromUriString(DETAIL_URL)
                .queryParam("d_isbn", isbn)
                .build()
                .encode(StandardCharsets.UTF_8)
                .toUri();
        return restTemplate.exchange(targetUrl, HttpMethod.GET, httpEntity, SearchDetailDto.class).getBody();
    }

이 결과를 담는 SearchDetailDto는

 

src/main/java/jpa/myunjuk/module/model/dto/search/SearchDetailDto.java

package jpa.myunjuk.module.model.dto.search;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.ArrayList;
import java.util.List;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SearchDetailDto {

    List<Items> items = new ArrayList<>();

    public static class Items{

        public String title;
        public String link;
        public String image;
        public String author;
        public String publisher;
        public String isbn;
        public String description;
    }
}

이렇게 생겼다. 중복이 점점 많아지는거 같은데 흠...

 

        List<SearchDetailDto.Items> items = searchInfo(isbn).getItems();
        if (searchInfo(isbn).getItems().isEmpty() || !Pattern.matches("^.{10}\\s.{13}$", isbn)) //검색 결과가 없거나, isbn 형식이 잘못됐을 때
            throw new InvalidReqParamException("isbn = " + isbn);

아무튼 이렇게 했는데 검색 결과가 없거나 isbn의 형식이 맞지 않으면 에러를 반환한다.

일반 검색을 통해 상세 검색으로 이동하므로 제대로 입력이 됐다면 검색 결과가 없는건 불가능하기 때문이다.

 

        SearchDetailDto.Items item = items.get(0);
        String[] str = isbn.split(" ");
        Integer page = pageInfo(str[1]); //13자리 isbn

입력에 문제가 없었다면 10자리, 13자리 isbn 중 뒷자리 13자리 isbn만 잘라낸다.

그리고 나서 pageInfo 메소드로 페이지 정보를 찾아온다.

 

    private Integer pageInfo(String isbn) {
        RestTemplate restTemplate = new RestTemplate();
        Map obj = restTemplate.getForObject(pageUrl, Map.class, isbn);
        Integer page = null;
        if (Objects.requireNonNull(obj).get("errorCode") == null) //페이지 정보가 있다면
            page = (Integer) ((HashMap) ((HashMap) ((List) obj.get("item")).get(0)).get("subInfo")).get("itemPage");
        return page;
    }

이건 알라딘 API를 호출해서 찾아오는데, 알라딘 API 호출엔 헤더가 딱히 필요없다.

그래서 restTemplate.getForObject로 불러온다. 그렇게 얻어낸 정보에서 페이지 정보만 찾아와야 하는데...

 

알라딘 API에서 결과를 찾지 못하면 이렇게 나오고

 

결과를 찾으면 이런식으로 나온다.

나한테 필요한건 저기 표시해둔 itemPage이다.

 

        if (Objects.requireNonNull(obj).get("errorCode") == null) //페이지 정보가 있다면
            page = (Integer) ((HashMap) ((HashMap) ((List) obj.get("item")).get(0)).get("subInfo")).get("itemPage");

그래서 만약 결과에 errorCode란 key가 없다면, 페이지 정보가 있다는 것이니까

알라딘 API 스펙보면서 잘...빼낸다.

 

src/main/java/jpa/myunjuk/module/controller/SearchController.java

package jpa.myunjuk.module.controller;

import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.dto.search.AddSearchDetailResDto;
import jpa.myunjuk.module.model.dto.search.SearchReqDto;
import jpa.myunjuk.module.service.SearchService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@Slf4j
@RestController
@RequestMapping("search")
@RequiredArgsConstructor
public class SearchController {

    private final SearchService searchService;

    /**
     * 책 상세 정보
     * localhost:8080/search/detail
     *
     * @param isbn
     * @return ResponseEntity
     */
    @GetMapping("/detail")
    public ResponseEntity<?> searchDetail(@RequestParam String isbn) {
        log.info("[Request] search detail");
        return new ResponseEntity<>(searchService.searchDetail(isbn), HttpStatus.OK);
    }
}

컨트롤러 작성하고

 

src/test/java/jpa/myunjuk/module/controller/SearchControllerTest.java

package jpa.myunjuk.module.controller;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.filter.CharacterEncodingFilter;

import java.util.HashMap;
import java.util.Map;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Transactional
@SpringBootTest
@DisplayName(value = "Book Search Test")
@AutoConfigureMockMvc(addFilters = false)
class SearchControllerTest {

    @Autowired
    MockMvc mockMvc;
    @Autowired
    ObjectMapper objectMapper;
    @Autowired
    WebApplicationContext webApplicationContext;

    @BeforeEach
    public void init() {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext)
                .addFilters(new CharacterEncodingFilter("UTF-8", true))
                .alwaysDo(print())
                .build();
    }

    @Test
    @DisplayName(value = "Search Detail | Success")
    void searchDetailSuccess() throws Exception {
        mockMvc.perform(get("/search/detail")
                .contentType(MediaType.APPLICATION_JSON)
                .param("isbn", "8952733789 9788952733788"))
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName(value = "Search Detail | Fail : Missing isbn")
    void searchDetailFailIsbn() throws Exception {
        Map<String, String> error = new HashMap<>();
        error.put("isbn", "Required request parameter 'isbn' for method parameter type String is not present");
        String response = objectMapper.writeValueAsString(error);

        mockMvc.perform(get("/search/detail")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }

    @Test
    @DisplayName(value = "Search Detail | Fail : Invalid isbn(Data)")
    void searchDetailFailInvalidIsbnData() throws Exception{
        Map<String, String> error = new HashMap<>();
        error.put("InvalidReqParamException", "isbn = djfkejf djfkejkf");
        String response = objectMapper.writeValueAsString(error);

        mockMvc.perform(get("/search/detail")
        .param("isbn", "djfkejf djfkejkf")
        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }

    @Test
    @DisplayName(value = "Search Detail | Fail : Invalid isbn(Format)")
    void searchDetailFailInvalidIsbnFormat() throws Exception{
        Map<String, String> error = new HashMap<>();
        error.put("InvalidReqParamException", "isbn = 8952733789 ");
        String response = objectMapper.writeValueAsString(error);

        mockMvc.perform(get("/search/detail")
                .param("isbn", "8952733789 ")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(content().string(response));
    }
}

테스트 코드 간단하게 작성하고...

그냥 뭐 isbn 형식만 체크한 셈이다.

 

잘된다요.

 

포스트맨 결과도 잘 나온다.


책 저장

 

1. 책 저장시엔 책의 정보와 유저가 저장할 책의 상태가 필요하다.

2. 책의 중복 저장 여부를 체크해야 한다.

3. '읽은 책'이 추가될 때는 사용자가 쌓은 책의 높이를 확인해서 추가되는 캐릭터를 확인해야 한다.

 

src/main/java/jpa/myunjuk/module/model/dto/search/SearchReqDto.java

package jpa.myunjuk.module.model.dto.search;

import jpa.myunjuk.infra.annotation.Enum;
import jpa.myunjuk.module.model.domain.BookStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;

import javax.validation.constraints.*;
import java.time.LocalDate;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SearchReqDto {

    @NotNull
    private String title;

    @NotNull
    private String url;
    private String thumbnail;
    private String author;
    private String publisher;

    @NotNull
    @Pattern(regexp = "^.{10}\\s.{13}$")
    private String isbn;
    private String description;
    private Integer totPage;

    @NotNull
    @Enum(enumClass = BookStatus.class, ignoreCase = true)
    private String bookStatus;

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @PastOrPresent
    private LocalDate startDate;

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @PastOrPresent
    private LocalDate endDate;

    @NotNull
    @Min(value = 0)
    @Max(value = 10)
    private Integer score;

    @NotNull
    @PositiveOrZero
    private Integer readPage;

    private String expectation;
}

저장하고자 하는 책의 정보와 저장 상태(읽은, 읽는 중, 읽고 싶음)를 입력받는다.

여기서 하는 validation으로 충분하진 않지만 일단 여기서 할 수 있는건 다 해보자.

 

    @NotNull
    @Pattern(regexp = "^.{10}\\s.{13}$")
    private String isbn;

isbn의 패턴

 

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @PastOrPresent
    private LocalDate startDate;

    @NotNull
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    @PastOrPresent
    private LocalDate endDate;

날짜의 입력형식과 시기

 

    @NotNull
    @Min(value = 0)
    @Max(value = 10)
    private Integer score;

    @NotNull
    @PositiveOrZero
    private Integer readPage;

점수와 읽은 페이지의 범위

 

    @NotNull
    @Enum(enumClass = BookStatus.class, ignoreCase = true)
    private String bookStatus;

마지막으로 Enum의 validation이다.

 

나머지는 어노테이션으로 해결할 수 있는데, enum은 약간 복잡하다.

일단 dto에서의 입력은 String이고, 이걸 enum으로 바꿔줘야 하는데

 

src/main/java/jpa/myunjuk/infra/annotation/Enum.java

package jpa.myunjuk.infra.annotation;

import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = {EnumValidator.class}) //annotation이 실행 할 ConstraintValidator 구현체
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) //메소드, 필드, 파라미터에 적용 가능
@Retention(RetentionPolicy.RUNTIME) //annotation을 Runtime까지 유지
public @interface Enum {
    String message() default "Invalid value. This is not permitted.";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
    Class<? extends java.lang.Enum<?>> enumClass();
    boolean ignoreCase() default false;
}

@Enum 어노테이션을 만들고

 

src/main/java/jpa/myunjuk/infra/annotation/EnumValidator.java

package jpa.myunjuk.infra.annotation;

import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;

public class EnumValidator implements ConstraintValidator<Enum, String> {

    private Enum annotation;

    @Override
    public void initialize(Enum constraintAnnotation) {
        this.annotation = constraintAnnotation;
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        boolean result = false;
        Object[] enumValues = this.annotation.enumClass().getEnumConstants();
        if (enumValues != null) {
            for (Object enumValue : enumValues) {
                if (value.equals(enumValue.toString()) ||
                        (this.annotation.ignoreCase() && value.equalsIgnoreCase(enumValue.toString()))) {
                    result = true;
                    break;
                }
            }
        }
        return result;
    }
}

validator를 만들고

 

src/main/java/jpa/myunjuk/module/model/domain/BookStatus.java

package jpa.myunjuk.module.model.domain;

import com.fasterxml.jackson.annotation.JsonCreator;

public enum BookStatus {
    DONE, READING, WISH;

    @JsonCreator
    public static BookStatus from(String value){
        return BookStatus.valueOf(value.toUpperCase());
    }
}

이렇게 String->Enum도 해주면 된다.

 

src/test/java/jpa/myunjuk/module/model/dto/search/SearchReqDtoTest.java

package jpa.myunjuk.module.model.dto.search;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;

import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("SearchReqDto Validation Test")
class SearchReqDtoTest {

    public static ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    public static Validator validator = factory.getValidator();

    @BeforeAll
    public static void init() {
        factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @Test
    @DisplayName("Search Request | Fail : Invalid isbn")
    void invalidIsbn() throws Exception {
        SearchReqDto req = SearchReqDto.builder()
                .title("Good Omens")
                .url("http://blahblah")
                .isbn("123 123")
                .bookStatus("done")
                .startDate(LocalDate.parse("2021-07-26"))
                .endDate(LocalDate.parse("2021-07-26"))
                .score(5)
                .readPage(1)
                .build();
        Set<ConstraintViolation<SearchReqDto>> violations = validator.validate(req);
        violations.forEach(error -> {
            assertThat(error.getMessage()).isEqualTo("\"^.{10}\\s.{13}$\"와 일치해야 합니다");
        });
    }

    @Test
    @DisplayName("Search Request | Fail : Invalid bookStatus")
    void invalidBookStatus() throws Exception {
        SearchReqDto req = SearchReqDto.builder()
                .title("Good Omens")
                .url("http://blahblah")
                .isbn("1234567890 1234567890123")
                .bookStatus("wrong")
                .startDate(LocalDate.parse("2021-07-26"))
                .endDate(LocalDate.parse("2021-07-26"))
                .score(5)
                .readPage(1)
                .build();
        Set<ConstraintViolation<SearchReqDto>> violations = validator.validate(req);
        violations.forEach(error -> {
            assertThat(error.getMessage()).isEqualTo("Invalid value. This is not permitted.");
        });
    }

    @Test
    @DisplayName("Search Request | Fail : Invalid Future Date")
    void invalidFutureDate() throws Exception {
        SearchReqDto req = SearchReqDto.builder()
                .title("Good Omens")
                .url("http://blahblah")
                .isbn("1234567890 1234567890123")
                .bookStatus("done")
                .startDate(LocalDate.parse("2021-07-28"))
                .endDate(LocalDate.parse("2021-07-26"))
                .score(5)
                .readPage(1)
                .build();
        Set<ConstraintViolation<SearchReqDto>> violations = validator.validate(req);
        violations.forEach(error -> {
            assertThat(error.getMessage()).isEqualTo("과거 또는 현재의 날짜여야 합니다");
        });
    }

    @Test
    @DisplayName("Search Request | Fail : Invalid Date Format")
    void invalidDateFormat() throws Exception {
        DateTimeParseException e = assertThrows(DateTimeParseException.class, () ->
                SearchReqDto.builder()
                        .title("Good Omens")
                        .url("http://blahblah")
                        .isbn("1234567890 1234567890123")
                        .bookStatus("done")
                        .startDate(LocalDate.parse("20210726"))
                        .endDate(LocalDate.parse("2021-07-26"))
                        .score(5)
                        .readPage(1)
                        .build());
        assertEquals("Text '20210726' could not be parsed at index 0", e.getMessage());
    }

    @Test
    @DisplayName("Search Request | Fail : Invalid Max score")
    void invalidMaxScore() throws Exception {
        SearchReqDto req = SearchReqDto.builder()
                .title("Good Omens")
                .url("http://blahblah")
                .isbn("1234567890 1234567890123")
                .bookStatus("done")
                .startDate(LocalDate.parse("2021-07-26"))
                .endDate(LocalDate.parse("2021-07-26"))
                .score(15)
                .readPage(1)
                .build();
        Set<ConstraintViolation<SearchReqDto>> violations = validator.validate(req);
        violations.forEach(error -> {
            assertThat(error.getMessage()).isEqualTo("10 이하여야 합니다");
        });
    }

    @Test
    @DisplayName("Search Request | Fail : Invalid Min score")
    void invalidMinScore() throws Exception {
        SearchReqDto req = SearchReqDto.builder()
                .title("Good Omens")
                .url("http://blahblah")
                .isbn("1234567890 1234567890123")
                .bookStatus("done")
                .startDate(LocalDate.parse("2021-07-26"))
                .endDate(LocalDate.parse("2021-07-26"))
                .score(-1)
                .readPage(1)
                .build();
        Set<ConstraintViolation<SearchReqDto>> violations = validator.validate(req);
        violations.forEach(error -> {
            assertThat(error.getMessage()).isEqualTo("0 이상이어야 합니다");
        });
    }

    @Test
    @DisplayName("Search Request | Fail : Invalid readPage")
    void invalidReadPage() throws Exception {
        SearchReqDto req = SearchReqDto.builder()
                .title("Good Omens")
                .url("http://blahblah")
                .isbn("1234567890 1234567890123")
                .bookStatus("done")
                .startDate(LocalDate.parse("2021-07-26"))
                .endDate(LocalDate.parse("2021-07-26"))
                .score(5)
                .readPage(-1)
                .build();
        Set<ConstraintViolation<SearchReqDto>> violations = validator.validate(req);
        violations.forEach(error -> {
            assertThat(error.getMessage()).isEqualTo("0 이상이어야 합니다");
        });
    }
}

테스트 코드 작성하고

 

 

src/main/java/jpa/myunjuk/module/service/SearchService.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.infra.exception.DuplicateException;
import jpa.myunjuk.infra.exception.InvalidReqBodyException;
import jpa.myunjuk.infra.exception.InvalidReqParamException;
import jpa.myunjuk.module.model.domain.Book;
import jpa.myunjuk.module.model.domain.BookStatus;
import jpa.myunjuk.module.model.domain.Characters;
import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.dto.search.*;
import jpa.myunjuk.module.repository.BookRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.*;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SearchService {

    @Value("${naver.id}")
    private String id;

    @Value("${naver.secret}")
    private String secret;

    @Value("${aladin.url}")
    private String pageUrl;
    private final String SEARCH_URL = "https://openapi.naver.com/v1/search/book.json?display=20";
    private final String DETAIL_URL = "https://openapi.naver.com/v1/search/book_adv.json";

    private final BookRepository bookRepository;
    private final CharactersService charactersService;

    /**
     * addSearchDetail
     *
     * @param user
     * @param searchReqDto
     * @return addSearchDetailResDto
     */
    @Transactional
    public AddSearchDetailResDto addSearchDetail(User user, SearchReqDto searchReqDto) {
        checkDuplicateBook(user, searchReqDto.getIsbn()); //중복 저장 확인
        validateDate(searchReqDto.getStartDate(), searchReqDto.getEndDate()); //날짜 선후관계 체크
        validateReadPage(searchReqDto.getReadPage(), searchReqDto.getTotPage()); //읽은 쪽수, 전체 쪽수 대소관계 체크

        Book save = bookRepository.save(buildBookFromReq(user, searchReqDto)); //책 저장
        AddSearchDetailResDto addSearchDetailResDto = null;
        if (save.getBookStatus() == BookStatus.DONE) { //저장할 책이 '읽은 책' 이라면
            Characters added = charactersService.addNewCharacters(user); //추가되는 캐릭터 중 가장 키가 큰 캐릭터
            if (added != null)
                addSearchDetailResDto = AddSearchDetailResDto.builder()
                        .id(added.getId())
                        .name(added.getName())
                        .img(added.getImg())
                        .build();
        }
        return addSearchDetailResDto;
    }

    private void checkDuplicateBook(User user, String isbn) {
        if (user.getBooks().stream()
                .map(Book::getIsbn)
                .collect(Collectors.toList()).contains(isbn))
            throw new DuplicateException("isbn = " + isbn);
    }

    private void validateReadPage(int readPage, Integer totPage) {
        if (totPage != null && readPage > totPage)
            throw new InvalidReqBodyException("page = " + readPage + " > " + totPage);
    }

    private void validateDate(LocalDate start, LocalDate end) {
        if (end.isBefore(start))
            throw new InvalidReqBodyException("date = " + start + " < " + end);
    }

    private Book buildBookFromReq(User user, SearchReqDto searchReqDto) {
        return Book.builder()
                .user(user)
                .title(searchReqDto.getTitle())
                .thumbnail(searchReqDto.getThumbnail())
                .author(searchReqDto.getAuthor())
                .publisher(searchReqDto.getPublisher())
                .description(searchReqDto.getDescription())
                .isbn(searchReqDto.getIsbn())
                .totPage(searchReqDto.getTotPage())
                .url(searchReqDto.getUrl())
                .bookStatus(BookStatus.from(searchReqDto.getBookStatus()))
                .startDate(searchReqDto.getStartDate())
                .endDate(searchReqDto.getEndDate())
                .score(searchReqDto.getScore())
                .readPage(searchReqDto.getReadPage())
                .expectation(searchReqDto.getExpectation())
                .build();
    }
}

하나하나 뜯어보면

 

        checkDuplicateBook(user, searchReqDto.getIsbn()); //중복 저장 확인
        validateDate(searchReqDto.getStartDate(), searchReqDto.getEndDate()); //날짜 선후관계 체크
        validateReadPage(searchReqDto.getReadPage(), searchReqDto.getTotPage()); //읽은 쪽수, 전체 쪽수 대소관계 체크

일단 RequestBody에서 하지 못한 validation을 해준다.

먼저 이미 저장된 책은 아닌지 확인하고, endDate가 startDate보다 뒤가 맞는지 확인하고,

읽은 쪽수가 전체 쪽수보다 크진 않은지 확인한다.

 

    private void checkDuplicateBook(User user, String isbn) {
        if (user.getBooks().stream()
                .map(Book::getIsbn)
                .collect(Collectors.toList()).contains(isbn))
            throw new DuplicateException("isbn = " + isbn);
    }

양방향으로 매핑했으니 사용자의 책을 바로 불러와서 isbn으로 묶어준 뒤, 이미 저장된 isbn인지 확인한다.

 

    private void validateReadPage(int readPage, Integer totPage) {
        if (totPage != null && readPage > totPage)
            throw new InvalidReqBodyException("page = " + readPage + " > " + totPage);
    }

    private void validateDate(LocalDate start, LocalDate end) {
        if (end.isBefore(start))
            throw new InvalidReqBodyException("date = " + start + " < " + end);
    }

이건 그냥 뭐...평범한 validation

 

        Book save = bookRepository.save(buildBookFromReq(user, searchReqDto)); //책 저장

validation을 다 통과했으면 드디어 책을 저장한다.

 

    private Book buildBookFromReq(User user, SearchReqDto searchReqDto) {
        return Book.builder()
                .user(user)
                .title(searchReqDto.getTitle())
                .thumbnail(searchReqDto.getThumbnail())
                .author(searchReqDto.getAuthor())
                .publisher(searchReqDto.getPublisher())
                .description(searchReqDto.getDescription())
                .isbn(searchReqDto.getIsbn())
                .totPage(searchReqDto.getTotPage())
                .url(searchReqDto.getUrl())
                .bookStatus(BookStatus.from(searchReqDto.getBookStatus()))
                .startDate(searchReqDto.getStartDate())
                .endDate(searchReqDto.getEndDate())
                .score(searchReqDto.getScore())
                .readPage(searchReqDto.getReadPage())
                .expectation(searchReqDto.getExpectation())
                .build();
    }

이것도 뭐 mapStruct?란걸 이용하면 쉽다는데 나중에 알아봐야겠다.

 

src/main/java/jpa/myunjuk/module/model/domain/Book.java

package jpa.myunjuk.module.model.domain;

import com.sun.istack.NotNull;
import lombok.*;
import org.springframework.util.Assert;

import javax.persistence.*;
import java.time.LocalDate;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {

    @Builder
    public Book(Long id, User user, String title, String thumbnail, String author, String publisher, String description,
                String isbn, Integer totPage, String url, BookStatus bookStatus, LocalDate startDate, LocalDate endDate,
                Integer score, Integer readPage, String expectation) {
        Assert.notNull(user, "User must not be null");
        Assert.notNull(title, "Title must not be null");
        Assert.notNull(isbn, "Isbn must not be null");
        Assert.notNull(url, "Url must not be null");

        this.id = id;
        this.user = user;
        this.title = title;
        this.thumbnail = thumbnail;
        this.author = author;
        this.publisher = publisher;
        this.description = description;
        this.isbn = isbn;
        this.totPage = totPage;
        this.url = url;
        this.bookStatus = bookStatus;
        this.startDate = startDate;
        this.endDate = endDate;
        this.score = score;
        this.readPage = readPage;
        this.expectation = expectation;
        if(!user.getBooks().contains(this))
            user.getBooks().add(this);
    }
}

양방향 매핑도 잊지 않고 한다. 칼럼이 솔직히 너무 많은 것 같은데 분리하기엔 의존이 심해서 조인 비용이 들 것 같다.

 

        AddSearchDetailResDto addSearchDetailResDto = null;
        if (save.getBookStatus() == BookStatus.DONE) { //저장할 책이 '읽은 책' 이라면
            Characters added = charactersService.addNewCharacters(user); //추가되는 캐릭터 중 가장 키가 큰 캐릭터
            if (added != null)
                addSearchDetailResDto = AddSearchDetailResDto.builder()
                        .id(added.getId())
                        .name(added.getName())
                        .img(added.getImg())
                        .build();
        }
        return addSearchDetailResDto;

저장한다고 끝이 아니다. 만약 저장한 책이 읽은 책이면 사용자가 읽은 책의 높이가 증가할 것이다.

그럼 새로 추가된 캐릭터가 있을지 모르니 확인해야 한다.

 

src/main/java/jpa/myunjuk/module/service/CharactersService.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.module.model.domain.Characters;
import jpa.myunjuk.module.model.domain.User;
import jpa.myunjuk.module.model.domain.UserCharacter;
import jpa.myunjuk.module.repository.CharactersRepository;
import jpa.myunjuk.module.repository.UserCharacterRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional
public class CharactersService {

    private final UserCharacterRepository userCharacterRepository;
    private final CharactersRepository charactersRepository;

    /**
     * addNewCharacters
     *
     * @param user
     * @return Characters
     */
    public Characters addNewCharacters(User user) {
        List<UserCharacter> list = charactersRepository.findByHeightLessThanEqualAndIdNotIn(user.bookHeight(), getCharactersFromUser(user)).stream()
                .sorted(Comparator.comparing(Characters::getHeight).reversed())
                .map(o -> UserCharacter.builder()
                        .user(user)
                        .characters(o)
                        .achieve(LocalDate.now())
                        .representation(false)
                        .build())
                .collect(Collectors.toList());
        if(!list.isEmpty()) {
            userCharacterRepository.saveAll(list);
            return list.get(0).getCharacters();
        }
        return null;
    }

    private List<Long> getCharactersFromUser(User user) {
        return user.getUserCharacters().stream()
                .map(o -> o.getCharacters().getId())
                .collect(Collectors.toList());
    }
}

바로 여기서 확인한다.

 

먼저 user.bookHeight로 사용자의 현재 책 높이를 가져온다.

 

src/main/java/jpa/myunjuk/module/model/domain/User.java

package jpa.myunjuk.module.model.domain;

import com.sun.istack.NotNull;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class User implements UserDetails {

    public double bookHeight() {
        return books.stream()
                .filter(o -> o.getTotPage() != null)
                .filter(o -> o.getBookStatus() == BookStatus.DONE)
                .mapToInt(Book::getTotPage).sum() * 0.005;
    }
}

그냥 뭐 사용자가 보유한 책 중에 BookStatus가 DONE이고 totPage가 null이 아닌 것들의 페이지를 다 더하고,

마지막에 0.005를 곱한다.

 

    private List<Long> getCharactersFromUser(User user) {
        return user.getUserCharacters().stream()
                .map(o -> o.getCharacters().getId())
                .collect(Collectors.toList());
    }

이 메소드로 사용자가 보유한 캐릭터의 pk를 모두 가져오고

 

charactersRepository.findByHeightLessThanEqualAndIdNotIn(user.bookHeight(), getCharactersFromUser(user))

사용자가 보유하지 않은 캐릭터들 중에서 사용자의 현재 책 높이보다 키가 작은 캐릭터를 모두 가져온다.

 

        List<UserCharacter> list = charactersRepository.findByHeightLessThanEqualAndIdNotIn(user.bookHeight(), getCharactersFromUser(user)).stream()
                .sorted(Comparator.comparing(Characters::getHeight).reversed())
                .map(o -> UserCharacter.builder()
                        .user(user)
                        .characters(o)
                        .achieve(LocalDate.now())
                        .representation(false)
                        .build())
                .collect(Collectors.toList());

그렇게 가져온 캐릭터들을 키로 정렬해서 UserCharacter 리스트로 만들어준다.

 

        if(!list.isEmpty()) {
            userCharacterRepository.saveAll(list);
            return list.get(0).getCharacters();
        }
        return null;

리스트가 비어있지 않다면 전부 저장한 뒤, 새롭게 추가된 캐릭터 중 키가 가장 큰 캐릭터 하나만 리턴한다.

 

        if (save.getBookStatus() == BookStatus.DONE) { //저장할 책이 '읽은 책' 이라면
            Characters added = charactersService.addNewCharacters(user); //추가되는 캐릭터 중 가장 키가 큰 캐릭터
            if (added != null)
                addSearchDetailResDto = AddSearchDetailResDto.builder()
                        .id(added.getId())
                        .name(added.getName())
                        .img(added.getImg())
                        .build();
        }
        return addSearchDetailResDto;

다시 searchService로 돌아와서...여기 최적화할 수 있을 것 같은데 일단 이렇게 짰다.

아무튼 추가된 캐릭터가 있다면 AddSearchDetailResDto에 넣어서 반환한다. 아니면 null 반환

 

src/main/java/jpa/myunjuk/module/model/dto/search/AddSearchDetailResDto.java

package jpa.myunjuk.module.model.dto.search;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AddSearchDetailResDto {

    private Long id;
    private String name;
    private String img;
}

그냥 화면에 보이는 정보만 넘긴다.

 

    /**
     * 책 상제 정보 조회 후 저장
     *
     * @param user
     * @param searchReqDto
     * @return ResponseEntity
     */
    @PostMapping("/detail")
    public ResponseEntity<?> searchDetail(@AuthenticationPrincipal User user, @Valid @RequestBody SearchReqDto searchReqDto) {
        log.info("[Request] Add book " + user.getEmail());
        AddSearchDetailResDto result = searchService.addSearchDetail(user, searchReqDto);
        if (result == null)
            return new ResponseEntity<>(HttpStatus.NO_CONTENT);
        return new ResponseEntity<>(result, HttpStatus.OK);
    }

컨트롤러에선 새롭게 추가된 캐릭터가 없다면 그냥 NO_CONTENT를 반환하고, 아니라면 OK와 함께 dto를 반환한다.

 

src/test/java/jpa/myunjuk/module/service/SearchServiceTest.java

package jpa.myunjuk.module.service;

import jpa.myunjuk.infra.exception.DuplicateException;
import jpa.myunjuk.infra.exception.InvalidReqBodyException;
import jpa.myunjuk.module.model.domain.*;
import jpa.myunjuk.module.model.dto.search.SearchReqDto;
import jpa.myunjuk.module.repository.CharactersRepository;
import jpa.myunjuk.module.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;

import static org.junit.jupiter.api.Assertions.*;

@SpringBootTest
@DisplayName("Search Service Test")
@Transactional
class SearchServiceTest {

    @Autowired
    UserRepository userRepository;

    @Autowired
    SearchService searchService;

    @Autowired
    CharactersRepository charactersRepository;

    @Test
    @DisplayName("Add Search Detail | Success")
    void addSearchDetailSuccess() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);
        int bookCnt = user.getBooks().size();
        int characterCnt = user.getUserCharacters().size();

        SearchReqDto searchReqDto = getSearchReqDto("done", "2021-07-26", 2000, 1);
        assertNotNull(searchService.addSearchDetail(user, searchReqDto));
        assertEquals(bookCnt + 1, user.getBooks().size());
        assertTrue(characterCnt == 8 || user.getUserCharacters().size() > characterCnt);
    }

    @Test
    @DisplayName("Add Search Detail | Success : Null Page")
    void addSearchDetailSuccessNullPage() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);
        int bookCnt = user.getBooks().size();

        SearchReqDto searchReqDto = getSearchReqDto("done", "2021-07-26", null, 1);
        assertNull(searchService.addSearchDetail(user, searchReqDto));
        assertEquals(bookCnt + 1, user.getBooks().size());
    }

    @Test
    @DisplayName("Add Search Detail | Success : READING")
    void addSearchDetailSuccessReading() {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);
        int bookCnt = user.getBooks().size();

        SearchReqDto searchReqDto = getSearchReqDto("reading", "2021-07-26", 2000, 1);
        assertNull(searchService.addSearchDetail(user, searchReqDto));
        assertEquals(bookCnt + 1, user.getBooks().size());
    }

    @Test
    @DisplayName("Add Search Detail | Fail : Invalid Date")
    void addSearchDetailFailInvalidDate() throws Exception {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);
        SearchReqDto searchReqDto = getSearchReqDto("done", "2021-07-20", 2000, 1);

        InvalidReqBodyException e = assertThrows(InvalidReqBodyException.class, () ->
                searchService.addSearchDetail(user, searchReqDto));
        assertEquals("date = 2021-07-26 < 2021-07-20", e.getMessage());
    }

    @Test
    @DisplayName("Add Search Detail | Fail : Invalid Page")
    void addSearchDetailFailInvalidPage() throws Exception {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);
        SearchReqDto searchReqDto = getSearchReqDto("done", "2021-07-26", 10, 1000);

        InvalidReqBodyException e = assertThrows(InvalidReqBodyException.class, () ->
                searchService.addSearchDetail(user, searchReqDto));
        assertEquals("page = 1000 > 10", e.getMessage());
    }

    @Test
    @DisplayName("Add Search Detail | Fail : Duplicate Book")
    void addSearchDetailFailDuplicateBook() throws Exception {
        User user = userRepository.findByEmail("test@test.com").orElse(null);
        assertNotNull(user);
        SearchReqDto searchReqDto1 = getSearchReqDto("done", "2021-07-26", 1000, 11);
        SearchReqDto searchReqDto2 = getSearchReqDto("reading", "2021-07-26", 1001, 10);

        DuplicateException e = assertThrows(DuplicateException.class, () -> {
            searchService.addSearchDetail(user, searchReqDto1);
            searchService.addSearchDetail(user, searchReqDto2);
        });
        assertEquals("isbn = 1234567890 1234567890123", e.getMessage());
    }

    private SearchReqDto getSearchReqDto(String bookStatus, String endDate, Integer totPage, int readPage) {
        return SearchReqDto.builder()
                .title("Good Omens")
                .url("http://blahblah")
                .isbn("1234567890 1234567890123")
                .bookStatus(bookStatus)
                .startDate(LocalDate.parse("2021-07-26"))
                .endDate(LocalDate.parse(endDate))
                .score(5)
                .totPage(totPage)
                .readPage(readPage)
                .build();
    }
}

다양한 상황에 대한 테스트를 작성하고

 

통과도 했는데 포스트맨에서 오류가 나더라

 

여기서 발생한 오류와 고친 과정을 볼 수 있다.

 

추가된 캐릭터가 있을 때
추가된 캐릭터가 없을 때


참고 블로그

 

[스프링] 오픈 api 사용해서 데이터 가져오기 (네이버 영화 검색 api)

Annotation으로 Enum 검증하기

[Spring] @RequestBody를 사용하여 Entity로 변환시 enum 타입 Mapping

 

 

 

 

 

Comments