-
Notifications
You must be signed in to change notification settings - Fork 178
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
Changes from all commits
94967eb
e2125f6
3673863
0f7fc61
f677720
9108ace
c4d68bd
60a4b64
c92e260
64d0078
97e31e3
87be5cc
460476a
001ab34
ac26ce6
b2503d4
d03ae06
c7ecbf5
609916b
f0df1e3
f1fdd20
e23283b
263aa9e
fc2d724
8e2b21c
da0d94c
6a6380f
8b44fb2
c07a3b6
a9854a4
acc6787
d5842e9
2f696cb
ea7e85c
949ecc2
2c4b46d
325ee65
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -35,3 +35,6 @@ out/ | |
|
||
### VS Code ### | ||
.vscode/ | ||
|
||
|
||
.db/ |
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) |
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") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 개인적인 취향인데요. 저는 |
||
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(); | ||
} | ||
} |
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"; | ||
} | ||
} |
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; | ||
} | ||
} |
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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Response에 대해 equals and hashCode를 구현해주신 이유가 있을까요? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
} |
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("서버 내부 오류가 발생했습니다."); | ||
} | ||
} |
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); | ||
} |
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); | ||
} | ||
} |
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); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
따로 기준이 있는 건 아닙니다.
@RestController
는 단순히@Controller
에@ResponseBody
가 있는 거라 필요한 상황에 따라 알맞게 쓰면 됩니다. 지금은 말씀대로 RespnoseBody 어노테이션이 쓸 상황이 아니라면 안 써도 됩니다. 잘 구현하셨어요! 👍There was a problem hiding this comment.
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 로 컨트롤러 분리를 해봤어요!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
일관성과 유지보수 측면에서 더 낫겠군요 👍