Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[1단계 - 상품 관리 기능] 제나(위예나) 미션 제출합니다 #189

Merged
merged 37 commits into from
May 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
94967eb
docs: 기능 요구 사항 작성
jaehee329 Apr 25, 2023
e2125f6
feat: Product entity 작성
jaehee329 Apr 25, 2023
3673863
feat: 상품 목록 페이지 작성
jaehee329 Apr 25, 2023
0f7fc61
feat: DDL 작성
jaehee329 Apr 25, 2023
f677720
feat: ProductDao save, findAll 메서드 구현
jaehee329 Apr 25, 2023
9108ace
feat: 상품 조회 API 구현
jaehee329 Apr 25, 2023
c4d68bd
feat: 상품 저장 API 구현
jaehee329 Apr 25, 2023
60a4b64
feat: 상품 수정 API 구현
jaehee329 Apr 25, 2023
c92e260
feat: 상품 삭제 API 구현
jaehee329 Apr 25, 2023
64d0078
refactor: product table ddl 변경
jaehee329 Apr 26, 2023
97e31e3
feat: CRUD에 맞게 html, js 수정
jaehee329 Apr 26, 2023
87be5cc
feat: 테이블과 api 수정에 따른 CRUD 구현
jaehee329 Apr 26, 2023
460476a
refactor : Long 자료형 통일
yenawee Apr 26, 2023
001ab34
test: Controller 테스트 구현
jaehee329 Apr 26, 2023
ac26ce6
test: Controller 테스트 구현
jaehee329 Apr 26, 2023
b2503d4
feat: 요청에 대한 서버에서의 유효성 검사 추가
jaehee329 Apr 26, 2023
d03ae06
conflict 해결
yenawee Apr 26, 2023
c7ecbf5
style : java convention
yenawee Apr 26, 2023
609916b
style : java convention
yenawee Apr 26, 2023
f0df1e3
conflict
yenawee Apr 26, 2023
f1fdd20
feat : 유효성 검증 테스트 추가
yenawee Apr 26, 2023
e23283b
docs : 1단계 피드백 사항 및 추가 수정사항 정리
yenawee Apr 30, 2023
263aa9e
feat : 상품목록 페이지 가격 표시 요구사항에 맞게 수정
yenawee Apr 30, 2023
fc2d724
refactor : View 를 반환하는 컨트롤러와 API 작업을 하는 컨트롤러 분리하기
yenawee Apr 30, 2023
8e2b21c
refactor : API 요청 시 반환되는 상태코드 수정
yenawee Apr 30, 2023
da0d94c
refactor : read -> readAll 메소드명 수정
yenawee Apr 30, 2023
6a6380f
refactor : Dao 에 제네릭 적용해보기
yenawee Apr 30, 2023
8b44fb2
.gitignore 에 db 디렉토리 추가
yenawee Apr 30, 2023
c07a3b6
feat : Controller 예외 핸들링 기능 추가
yenawee May 1, 2023
a9854a4
refactor : @RestController 로 수정
yenawee May 1, 2023
acc6787
feat : production DB url 변경, 실행 시 더미 데이터 추가
yenawee May 1, 2023
d5842e9
feat : test db 분리
yenawee May 1, 2023
2f696cb
feat : findByName 삭제, findById 추가
yenawee May 1, 2023
ea7e85c
feat : service layer test 추가
yenawee May 1, 2023
949ecc2
feat : findById 반환값 Optional 로 변경
yenawee May 1, 2023
2c4b46d
feat : ProductEntity, ProductResponse 객체 equals, hashcode 재정의
yenawee May 1, 2023
325ee65
style : java convention
yenawee May 1, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ out/

### VS Code ###
.vscode/


.db/
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,29 @@
# jwp-shopping-cart

## 1단계 피드백 사항 및 추가 수정 사항
- [x] view의 객체(ProductRequest) 가 Service 에 그대로 전달되었을 때 생기는 문제점
- [x] 컨트롤러에서 Service 에서 사용되는 값들을 알게 되면 안 좋은 점
- [x] View 를 반환하는 컨트롤러와 API 작업을 하는 컨트롤러 분리하기
- [x] Model이 반환되는 과정
- [x] ModelAndView 학습
- [x] 아무것도 반환하지 않을 때 200을 반환한 이유 / 어떤 값을 반환해야 하는지 학습
- [x] Dao 에 제네릭 적용해보기
- [x] update, delete 시 해당하는 id 가 없을 때 예외 핸들링
- [x] Service layer 테스트 추가
- [x] 테스트 DB 분리
- [ ] 테스트 픽스처 적용해보기
- [x] 어플리케이션 초기 실행 시 상품 목록에 더미데이터 추가



## 1단계 기능 요구 사항

- [x] 상품 목록 페이지 연동
- [x] / url로 접근할 경우 index.html을 통해 상품 목록 페이지를 조회 가능하다.
- [x] 상품 기본 정보는 상품 ID, 상품 이름, 상품 이미지, 상품 가격이다.

- [x] 관리자 도구 페이지 상품 관리 CRUD API
- [x] 생성(/admin/product POST)
- [x] 조회(/admin)
- [x] 수정(/admin/product/{id} PUT)
- [x] 삭제(/admin/product/{id} DELETE)
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'io.rest-assured:rest-assured:4.4.0'
Expand Down
1 change: 0 additions & 1 deletion src/main/java/cart/JwpCartApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,4 @@ public class JwpCartApplication {
public static void main(String[] args) {
SpringApplication.run(JwpCartApplication.class, args);
}

}
38 changes: 38 additions & 0 deletions src/main/java/cart/controller/AdminController.java

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

controller 분리 기준을 어떻게 해야 하는지
현재는 url 기준으로 AdminController 와 RootController 를 구분하였는데 index.html, admin.html 을 반환하는 메소드들은 @responsebody 어노테이션이 필요가 없어서 둘 다 @controller 어노테이션만 사용했습니다. 이럴때는 @RestController@controller 로 분리하는게 좋을지 아니면 현재처럼 기능(url?)별로 묶는 것이 좋은지 기준점이 궁금합니다 !

따로 기준이 있는 건 아닙니다. @RestController는 단순히 @Controller@ResponseBody가 있는 거라 필요한 상황에 따라 알맞게 쓰면 됩니다. 지금은 말씀대로 RespnoseBody 어노테이션이 쓸 상황이 아니라면 안 써도 됩니다. 잘 구현하셨어요! 👍

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리팩토링을 하면서 다른 크루들 코드를 구경하다보니 View 를 반환하는 컨트롤러와 API 작업을 수행하는 컨트롤러를 구분하는 경우가 많이 보였습니다!
확실히 View 반환하는 컨트롤러만 반환값이 다르기도 하고 추후에 API 가 추가되었을때나 View 페이지 변경이 필요할 때 컨트롤러를 나눠서 관리하는게 수정에 용이할 거라고 생각이 들어서 저도 ViewController 와 AdminController 로 컨트롤러 분리를 해봤어요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일관성과 유지보수 측면에서 더 낫겠군요 👍

Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package cart.controller;

import cart.dto.ProductRequest;
import cart.service.CartService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;
import java.net.URI;

@RestController
@RequestMapping("/admin")
public class AdminController {

private final CartService cartService;

public AdminController(final CartService cartService) {
this.cartService = cartService;
}

@PostMapping("/product")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 취향인데요. 저는 @RequestMapping을 없애고 중복이 되더라도 URL을 분리하지 않고 한 번에 쓰는 편입니다. (e.g. @PostMapping("/admin/product"))
그래야 찾기 편하더라구요ㅎㅎ (물론 분리해도 shift + shift로 뜨는 창에서 /url 검색할 URL 이런 식으로 찾을 수 있긴 합니다)

public ResponseEntity<Void> createProduct(@RequestBody @Valid final ProductRequest productRequest) {
final long id = cartService.create(productRequest);
return ResponseEntity.created(URI.create("/admin/product/" + id)).build();
}

@PutMapping("/product/{id}")
public ResponseEntity<Void> updateProduct(@PathVariable Long id, @RequestBody @Valid final ProductRequest productRequest) {
cartService.update(id, productRequest);
return ResponseEntity.noContent().build();
}

@DeleteMapping("/product/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
cartService.delete(id);
return ResponseEntity.noContent().build();
}
}
33 changes: 33 additions & 0 deletions src/main/java/cart/controller/ViewController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package cart.controller;

import cart.dto.ProductResponse;
import cart.service.CartService;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

@Controller
public class ViewController {

private final CartService cartService;

public ViewController(final CartService cartService) {
this.cartService = cartService;
}

@GetMapping("/")
public String getIndex(final Model model) {
List<ProductResponse> products = cartService.readAll();
model.addAttribute("products", products);
return "index";
}

@GetMapping("/admin")
public String getAdmin(final Model model) {
List<ProductResponse> products = cartService.readAll();
model.addAttribute("products", products);
return "admin";
}
}
42 changes: 42 additions & 0 deletions src/main/java/cart/dto/ProductRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package cart.dto;

import org.hibernate.validator.constraints.URL;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

public class ProductRequest {

@NotBlank(message = "상품 명을 입력해주세요.")
private String name;

@NotNull
@Min(value = 0, message = "{value} 이상의 가격을 입력해주세요")
private int price;

@NotBlank
@URL(message = "유효한 URL 형식을 입력해주세요")
private String imageUrl;

private ProductRequest() {
}

public ProductRequest(final String name, final int price, final String imageUrl) {
this.name = name;
this.price = price;
this.imageUrl = imageUrl;
}

public String getName() {
return name;
}

public int getPrice() {
return price;
}

public String getImageUrl() {
return imageUrl;
}
}
54 changes: 54 additions & 0 deletions src/main/java/cart/dto/ProductResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cart.dto;

import cart.persistence.entity.ProductEntity;

import java.util.Objects;

public class ProductResponse {

private final Long id;
private final String name;
private final int price;
private final String imageUrl;

public ProductResponse(final Long id, final String name, final int price, final String imageUrl) {
this.id = id;
this.name = name;
this.price = price;
this.imageUrl = imageUrl;
}

public static ProductResponse from(final ProductEntity productEntity) {
return new ProductResponse(productEntity.getId(), productEntity.getName(),
productEntity.getPrice(), productEntity.getImageUrl());
}

public Long getId() {
return id;
}

public String getName() {
return name;
}

public int getPrice() {
return price;
}

public String getImageUrl() {
return imageUrl;
}

@Override
public boolean equals(Object o) {
Comment on lines +42 to +43

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response에 대해 equals and hashCode를 구현해주신 이유가 있을까요?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

 @Test
    void 전체_상품을_조회한다() {
        //given
        List<ProductEntity> givenProducts = List.of(new ProductEntity("jena", 90, "http://naver.com"),
                new ProductEntity("modi", 50, "http://daum.com"));
        given(productDao.findAll()).willReturn(givenProducts);

        //when
        List<ProductResponse> products = cartService.readAll();

        //then
        assertThat(products).isEqualTo(givenProducts.stream()
                .map(ProductResponse::from)
                .collect(Collectors.toList()));
    }

이 테스트를 실행할 때 productDao.findAll() 은 ProductEntity 를 반환하고 cartService.readAll() 은 ProductResponse 를 반환해서 equal 을 검증해주려면 equals, hashCode 를 재구현해주었습니다! ProductResponse 도 약간 Entity 느낌이라서 필드의 값들이 같으면 같은 객체라고 판단해도 된다고 생각했습니다 !!

if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ProductResponse that = (ProductResponse) o;
return price == that.price && Objects.equals(id, that.id) && Objects.equals(name, that.name) && Objects.equals(imageUrl, that.imageUrl);
}

@Override
public int hashCode() {
return Objects.hash(id, name, price, imageUrl);
}
}
26 changes: 26 additions & 0 deletions src/main/java/cart/exception/CartControllerAdvice.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cart.exception;

import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class CartControllerAdvice {
@ExceptionHandler({EmptyResultDataAccessException.class})
public ResponseEntity<String> handleEmptyDataAccessException(final EmptyResultDataAccessException e) {
return ResponseEntity.badRequest().body("해당하는 상품이 없습니다.");
}

@ExceptionHandler({MethodArgumentNotValidException.class})
public ResponseEntity<String> handleValidationException(final MethodArgumentNotValidException e) {
final String errorMessage = "잘못된 요청입니다. " + e.getFieldErrors().get(0).getDefaultMessage();
return ResponseEntity.badRequest().body(errorMessage);
}

@ExceptionHandler({Exception.class})
public ResponseEntity<String> handleException(final Exception e) {
return ResponseEntity.internalServerError().body("서버 내부 오류가 발생했습니다.");
}
}
17 changes: 17 additions & 0 deletions src/main/java/cart/persistence/dao/Dao.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package cart.persistence.dao;

import java.util.List;
import java.util.Optional;

public interface Dao<T> {

Long save(T t);

Optional<T> findById(Long id);

List<T> findAll();

void update(T t);

void deleteById(long id);
}
62 changes: 62 additions & 0 deletions src/main/java/cart/persistence/dao/JdbcProductDao.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package cart.persistence.dao;

import cart.persistence.entity.ProductEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public class JdbcProductDao implements Dao<ProductEntity> {

private final JdbcTemplate jdbcTemplate;
private final SimpleJdbcInsert simpleJdbcInsert;
private final RowMapper<ProductEntity> actorRowMapper = (resultSet, rowNum) -> new ProductEntity(
resultSet.getLong("product_id"),
resultSet.getString("name"),
resultSet.getInt("price"),
resultSet.getString("image_url")
);

public JdbcProductDao(final JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
this.simpleJdbcInsert = new SimpleJdbcInsert(jdbcTemplate)
.withTableName("PRODUCT")
.usingGeneratedKeyColumns("product_id");
}

@Override
public Long save(final ProductEntity productEntity) {
final SqlParameterSource parameters = new BeanPropertySqlParameterSource(productEntity);
return simpleJdbcInsert.executeAndReturnKey(parameters).longValue();
}

@Override
public Optional<ProductEntity> findById(final Long id) {
final String sql = "SELECT product_id, name, price, image_url FROM product WHERE product_id = ?";
return Optional.ofNullable(jdbcTemplate.queryForObject(sql, actorRowMapper, id));
}

@Override
public List<ProductEntity> findAll() {
final String sql = "SELECT product_id, name, price, image_url FROM product";
return jdbcTemplate.query(sql, actorRowMapper);
}

@Override
public void update(final ProductEntity productEntity) {
final String sql = "UPDATE product SET name=?, price=?, image_url=? WHERE product_id = ?";
jdbcTemplate.update(sql, productEntity.getName(), productEntity.getPrice(), productEntity.getImageUrl(), productEntity.getId());
}

@Override
public void deleteById(final long id) {
final String sql = "DELETE FROM product WHERE product_id = ?";
jdbcTemplate.update(sql, id);
}
}
51 changes: 51 additions & 0 deletions src/main/java/cart/persistence/entity/ProductEntity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cart.persistence.entity;

import java.util.Objects;

public class ProductEntity {

private final Long id;
private final String name;
private final int price;
private final String imageUrl;

public ProductEntity(final Long id, final String name, final int price, final String imageUrl) {
this.id = id;
this.name = name;
this.price = price;
this.imageUrl = imageUrl;
}

public ProductEntity(final String name, final int price, final String imageUrl) {
this(null, name, price, imageUrl);
}

public Long getId() {
return id;
}

public String getName() {
return name;
}

public int getPrice() {
return price;
}

public String getImageUrl() {
return imageUrl;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
ProductEntity that = (ProductEntity) o;
return price == that.price && Objects.equals(id, that.id) && Objects.equals(name, that.name) && Objects.equals(imageUrl, that.imageUrl);
}

@Override
public int hashCode() {
return Objects.hash(id, name, price, imageUrl);
}
}
Loading