<repo_name>ssang1105/my-book<file_sep>/src/main/java/com/supersw/mybook/client/book/kakao/resposne/Meta.java
package com.supersw.mybook.client.book.kakao.resposne;

import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

@Getter
public class Meta {

    @JsonProperty("is_end")
    private boolean isEnd;
    @JsonProperty("pageable_count")
    private int pagableCount;
    @JsonProperty("total_count")
    private int totalCount;
}<file_sep>/src/main/java/com/supersw/mybook/error/exceptions/NoAuthorityException.java
package com.supersw.mybook.error.exceptions;

public class NoAuthorityException extends RuntimeException {

    public NoAuthorityException(String message) {
        super(message);
    }

}
<file_sep>/src/main/java/com/supersw/mybook/client/exceptions/ClientException.java
package com.supersw.mybook.client.exceptions;

public class ClientException extends RuntimeException {

    private static final long serialVersionUID = 8256583033452927847L;

    public ClientException(int statusCode, String request, String response) {
        super("statusCode: " + statusCode + "\nrequest: " + request + "\nresponse: " + response);
    }

    public ClientException(String message, Throwable t) {
        super(message, t);
    }
}
<file_sep>/src/main/java/com/supersw/mybook/member/domain/MemberJoinRequest.java
package com.supersw.mybook.member.domain;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

@Getter
public class MemberJoinRequest {

    private final String id;
    private final String password;

    @JsonCreator
    public MemberJoinRequest(@JsonProperty("username") String id, @JsonProperty("password") String password) {
        this.id = id;
        this.password = <PASSWORD>;
    }

}
<file_sep>/src/main/java/com/supersw/mybook/config/JacksonConfig.java
package com.supersw.mybook.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.DeserializationConfig;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class JacksonConfig {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new ParameterNamesModule())
            .registerModule(new Jdk8Module())
            .registerModule(new JavaTimeModule())
            .setVisibility(PropertyAccessor.FIELD, Visibility.ANY)
            .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }
}
<file_sep>/src/main/java/com/supersw/mybook/member/service/MemberService.java
package com.supersw.mybook.member.service;

import com.supersw.mybook.error.exceptions.AlreadyExistMemberException;
import com.supersw.mybook.error.exceptions.NotExistMemberException;
import com.supersw.mybook.member.domain.Member;
import com.supersw.mybook.member.repository.MemberRepository;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.Base64Utils;

@RequiredArgsConstructor
@Service
public class MemberService {

    private final MemberRepository memberRepository;

    public Member join(@NonNull String id, @NonNull String password) {
        if (memberRepository.findByMemberId(id) != null) {
            throw new AlreadyExistMemberException("이미 존재하는 회원 아이디입니다. 회원 아이디 : " + id);
        }

        Member member = Member.create(id, Base64Utils.encodeToString(password.getBytes()));
        return memberRepository.save(member);
    }

    public Member login(@NonNull String id, @NonNull String password) {
        Member member = memberRepository.findByMemberIdAndPassword(id, Base64Utils.encodeToString(password.getBytes()));
        if (member == null) {
            throw new NotExistMemberException("존재하지 않는 회원입니다. 회원 아이디 : " + id);
        }
        return member;
    }

    public Member find(@NonNull String id) {
        return memberRepository.findByMemberId(id);
    }

}<file_sep>/src/main/java/com/supersw/mybook/book/service/BookService.java
package com.supersw.mybook.book.service;

import com.supersw.mybook.book.domain.BookSearchResult;
import com.supersw.mybook.client.book.BookSearchClientResponse;
import com.supersw.mybook.client.book.kakao.KakaoBookClient;
import com.supersw.mybook.client.book.naver.NaverBookClient;
import com.supersw.mybook.client.book.request.BookSearchParam;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
@Slf4j
public class BookService {

    private final KakaoBookClient kakaoBookClient;
    private final NaverBookClient naverBookClient;
    private final BookSearchHistoryService bookSearchHistoryService;


    public BookSearchResult searchBooks(@NonNull String query, int page, int size, String memberId) {
        BookSearchParam bookSearchParam = BookSearchParam.create(query, page, size);
        bookSearchHistoryService.save(query, memberId);
        BookSearchClientResponse bookSearchClientResponse = null;
        try {
            bookSearchClientResponse = kakaoBookClient.searchBooks(bookSearchParam);
        } catch (Exception e) {
            log.error(e.getMessage(), e);
            log.info("### KAKAO 책 API 가 실패하여 NAVER 책 API 를 호출합니다.");
            bookSearchClientResponse = naverBookClient.searchBooks(bookSearchParam);
        }
        return BookSearchResult.of(bookSearchClientResponse);
    }

}
<file_sep>/src/main/java/com/supersw/mybook/client/book/kakao/KakaoBookClient.java
package com.supersw.mybook.client.book.kakao;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.supersw.mybook.client.ClientTemplate;
import com.supersw.mybook.client.book.BookClient;
import com.supersw.mybook.client.book.BookSearchClientResponse;
import com.supersw.mybook.client.book.kakao.resposne.KakaoResponse;
import com.supersw.mybook.client.book.kakao.resposne.KakaoBookSearchResponse;
import com.supersw.mybook.client.book.request.BookSearchParam;
import java.net.URI;
import lombok.Getter;
import lombok.NonNull;
import org.apache.http.client.fluent.Request;
import org.apache.http.client.utils.URIBuilder;

public class KakaoBookClient extends ClientTemplate<KakaoResponse> implements BookClient {

    private final int connectTimeout;
    private final int socketTimeout;
    private final String appKey;
    @Getter
    private final ObjectMapper objectMapper;

    private final URI baseUri;
    private static final String searchBookUri = "/v3/search/book";

    private static final TypeReference<KakaoBookSearchResponse> kakaoSearchBookResponseTypeReference = new TypeReference<KakaoBookSearchResponse>() {
    };


    public KakaoBookClient(@NonNull ObjectMapper objectMapper, @NonNull KakaoBookClientProperties properties) {
        this.objectMapper = objectMapper;
        this.connectTimeout = properties.getConnectTimeout();
        this.socketTimeout = properties.getSocketTimeout();
        this.appKey = properties.getAppKey();
        this.baseUri = properties.getBaseUri();
    }

    @Override
    public BookSearchClientResponse searchBooks(BookSearchParam param) {
        String uri = new URIBuilder(baseUri).setPath(searchBookUri)
                                            .addParameter("query", param.getQuery())
                                            .addParameter("page", String.valueOf(param.getPage()))
                                            .addParameter("size", String.valueOf(param.getSize()))
                                            .toString();
        Request request = Request.Get(uri)
                                 .addHeader("Authorization", "KakaoAK " + appKey);
        KakaoBookSearchResponse response = execute(request, kakaoSearchBookResponseTypeReference);
        return BookSearchClientResponse.of(response);
    }

    @Override
    protected void initRequestHeader(Request request) {
        super.initRequestHeader(request);
        request.connectTimeout(this.connectTimeout)
               .socketTimeout(this.socketTimeout);
    }

}
<file_sep>/src/main/resources/static/js/mybook.main.js
(function () {

      $('#join-btn').click(function () {
        var username = $("input[name=username]").val();
        if (!username) {
          alert("아이디를 입력해주세요.");
          return;
        }
        var password = $("input[name=password]").val();
        if (!username) {
          alert("패스워드를 입력해주세요.");
          return;
        }
        var data = {
          username: username,
          password: <PASSWORD>
        };

        $.ajax({
          url: location.protocol + '//' + location.host + '/api/members',
          method: 'POST',
          contentType: 'application/json',
          data: JSON.stringify(data),
          success: function (response) {
            alert("회원가입 성공!");
            window.location.reload();
          },
          error: function (result) {
            if (result.responseJSON.userMessage) {
              alert(result.responseJSON.userMessage);
            } else {
              alert("서버 오류입니다.\n잠시 후 다시 시도해주세요.");
            }
          }
        });

      });

      $('#login-btn').click(function () {
        var username = $("input[name=username]").val();
        if (!username) {
          alert("아이디를 입력해주세요.");
          return;
        }
        var password = $("input[name=password]").val();
        if (!username) {
          alert("패스워드를 입력해주세요.");
          return;
        }
        var data = {
          username: username,
          password: <PASSWORD>
        };

        $.ajax({
          url: location.protocol + '//' + location.host + '/api/members/login',
          method: 'POST',
          contentType: 'application/json',
          data: JSON.stringify(data),
          success: function (response) {
            alert("로그인 성공!");
            window.location.reload();
          },
          error: function (result) {
            if (result.responseJSON.userMessage) {
              alert(result.responseJSON.userMessage);
            } else {
              alert("서버 오류입니다.\n잠시 후 다시 시도해주세요.");
            }
          }
        });
      });

      $('#search-btn').click(function () {
        var query = $("input[name=bookName]").val();
        if (!query) {
          alert("검색할 책 제목을 입력해주세요.");
          return;
        }
        searchBook(query, 1);
      });

      $('#search-hist-btn').click(function () {
        var $searchHistList = $('.search-hist-list');
        if ($searchHistList.is(":visible")) {
          $searchHistList.hide();
          $searchHistList.children().remove();
          return;
        } else {
          $searchHistList.show();
        }

        var memberId = $("input[name=memberId]").val();
        $.ajax({
          url: location.protocol + '//' + location.host + '/api/book-search-histories?memberId=' + memberId,
          method: 'GET',
          success: function (response) {
            if ($.isEmptyObject(response)) {
              $('.search-hist-list').append('<strong">검색 내역이 없습니다.</strong>');
              return;
            }
            $.each(response, function (i, hist) {
              appendHistToList(hist);
            });

          },
          error: function (result) {
            if (result.responseJSON.userMessage) {
              alert(result.responseJSON.userMessage);
            } else {
              alert("서버 오류입니다.\n잠시 후 다시 시도해주세요.");
            }
          }
        });
      });

      $('#popular-keyword-btn').click(function () {
        var popularKeywordList = $('.popular-keyword-list');
        if (popularKeywordList.is(":visible")) {
          popularKeywordList.hide();
          popularKeywordList.children().remove();
          return;
        } else {
          popularKeywordList.show();
        }

        $.ajax({
          url: location.protocol + '//' + location.host + '/api/book-search-histories/summary',
          method: 'GET',
          success: function (response) {
            if ($.isEmptyObject(response)) {
              $('.search-hist-list').append('<strong">아직 검색한 내역이 없습니다.</strong>');
              return;
            }
            $.each(response, function (i, summary) {
              appendSummaryToList(summary);
            });

          },
          error: function (result) {
            if (result.responseJSON.userMessage) {
              alert(result.responseJSON.userMessage);
            } else {
              alert("서버 오류입니다.\n잠시 후 다시 시도해주세요.");
            }
          }
        });
      });

      $(document).on("click", '#next-page-btn', function () {
        var nextPageNum = parseInt($("input[name=pageNum]").val()) + 1;
        $("input[name=pageNum]").val(nextPageNum);
        searchBook($("input[name=query]").val(), nextPageNum);
      });

      $(document).on("click", '#prev-page-btn', function () {
        var prevPageNum = parseInt($("input[name=pageNum]").val()) - 1;
        $("input[name=pageNum]").val(prevPageNum);
        searchBook($("input[name=query]").val(), prevPageNum);
      });

      $(document).on("click", '.book-title', function () {
        var $bookInfo = $(this).parent().find('.book-info');
        if ($bookInfo.is(":visible")) {
          $bookInfo.hide();
        } else {
          $bookInfo.show();
        }
      });

      function searchBook(query, page) {
        $.ajax({
          url: location.protocol + '//' + location.host + '/api/books?query=' + query + '&page=' + page,
          method: 'GET',
          success: function (response) {
            $('.book-list').children().remove();
            if ($.isEmptyObject(response.books)) {
              $('.book-list').append('<strong">검색 결과가 없습니다.</strong>');
              return;
            }
            $.each(response.books, function (i, book) {
              appendBookToList(book);
            });

            if (page === 0 && $('.book-list').has('#prev-page-btn').length > 0) {
              $('#prev-page-btn').remove();
            } else if (page > 1) {
              $('.book-list').append('<strong id="prev-page-btn" style="color: red">이전 페이지</strong>\t');
            }

            if (response.page.isEnd) {
              $('#next-page-btn').remove();
            } else if ($('.book-list').has('#next-page-btn').length === 0) {
              $('.book-list').append('<strong id="next-page-btn" style="color: darkblue">다음 페이지</strong>');
            }

            $('.book-list').append("<input type='hidden' name='pageNum' value='" + page + "'/>");
            $('.book-list').append("<input type='hidden' name='query' value='" + query + "'/>");

          },
          error: function (result) {
            if (result.responseJSON.userMessage) {
              alert(result.responseJSON.userMessage);
            } else {
              alert("서버 오류입니다.\n잠시 후 다시 시도해주세요.");
            }
          }
        });
      }

      function appendSummaryToList(summary) {
        $('.popular-keyword-list').append(
            '<li>\n'
            + '            <strong class="keyword-name">' + summary.keyword + '</strong>\n'
            + '            <strong class="hist-datetime">' + summary.searchCount + '</strong>\n'
            + '          </li>'
        );
      }

      function appendHistToList(hist) {
        $('.search-hist-list').append(
            '<li>\n'
            + '            <strong class="hist-keyword">' + hist.keyword + '</strong>\n'
            + '            <strong class="hist-datetime">' + hist.registeredDateTime + '</strong>\n'
            + '          </li>'
        );
      }

      function appendBookToList(book) {
        $('.book-list').append(
            '<li>\n'
            + '            <strong class="book-title">' + book.title + '</strong>\n'
            + '            <div class="book-info" style="display: none">\n'
            + '              <dl>'
            + '                 <img src="' + book.thumbnail + '">\n'
            + '                <dd>' + book.contents + '</dd>\n'
            + '              </dl>\n'
            + '              <dl>\n'
            + '                <dt><span>저자</span></dt>\n'
            + '                <dd>' + book.authors + '</dd>\n'
            + '                <dt><span">ISBN</span></dt>\n'
            + '                <dd>' + book.isbn + '</dd>\n'
            + '                <dt><span>출판사</span></dt>\n'
            + '                <dd>' + book.publisher + '</dd>\n'
            + '                <dt><span>출판일</span></dt>\n'
            + '                <dd>' + book.datetime + '</dd>\n'
            + '                <dt><span>정가</span></dt>\n'
            + '                <dd>' + book.price + '</dd>\n'
            + '                <dt><span>판매가</span></dt>\n'
            + '                <dd>' + book.salePrice + '</dd>\n'
            + '              </dl>\n'
            + '            </div>\n'
            + '          </li>'
        );
      }

    }
)();<file_sep>/src/main/java/com/supersw/mybook/client/book/BookClient.java
package com.supersw.mybook.client.book;

import com.supersw.mybook.client.book.request.BookSearchParam;

public interface BookClient {

    BookSearchClientResponse searchBooks(BookSearchParam param);
}
<file_sep>/src/main/java/com/supersw/mybook/member/domain/MemberBookSearchRequest.java
package com.supersw.mybook.member.domain;

public class MemberBookSearchRequest {

}
<file_sep>/README.md
# my-book
## executable-jar 다운로드 URL 
* https://github.com/ssang1105/my-book/raw/master/my-book-0.0.1-SNAPSHOT.jar

## 사용한 라이브러리
* spring-boot-starter-* : 스프링 의존성과 설정 자동화
* fluentHc : HttpClient 생성
* randomBeansVersion : 테스트환경에서 랜덤 객체 생성
* lombok : 어노테이션을 이용한 코드 추가
<file_sep>/src/main/java/com/supersw/mybook/client/book/naver/response/NaverBookSearchResponse.java
package com.supersw.mybook.client.book.naver.response;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.supersw.mybook.client.book.naver.response.NaverBookSearchResponse.NaverBookSearchResponseData;
import java.time.LocalDate;
import java.util.List;
import lombok.Getter;

public class NaverBookSearchResponse extends NaverResponse<NaverBookSearchResponseData.Item> {

    @Getter
    public static class NaverBookSearchResponseData {

        @Getter
        private List<Item> items;

        @Getter
        public static class Item {
            private String title;
            private String link;
            private String image;
            private String author;
            private int price;
            private int discount;
            private String publisher;
            private String isbn;
            private String description;
            @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyyMMdd")
            private LocalDate pubdate;
        }
    }

}
<file_sep>/src/main/java/com/supersw/mybook/client/book/request/BookSearchParam.java
package com.supersw.mybook.client.book.request;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public class BookSearchParam {

    private final String query;
    private final int page;
    private final int size;


    public static BookSearchParam create(String query, int page, int size) {
        return new BookSearchParam(query, page, size);
    }

}
<file_sep>/src/main/java/com/supersw/mybook/error/exceptions/AlreadyExistMemberException.java
package com.supersw.mybook.error.exceptions;

public class AlreadyExistMemberException extends RuntimeException {
    private static final long serialVersionUID = 2622515567353191285L;

    public AlreadyExistMemberException(String message) {
        super(message);
    }
}
<file_sep>/src/main/java/com/supersw/mybook/book/domain/BookSearchHistorySummary.java
package com.supersw.mybook.book.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public class BookSearchHistorySummary {

    private final String keyword;
    private final long searchCount;

}
