diff --git a/build.gradle b/build.gradle index 57267157c..6f44b469e 100644 --- a/build.gradle +++ b/build.gradle @@ -16,8 +16,18 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'io.rest-assured:rest-assured:5.3.1' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + compileOnly 'org.projectlombok:lombok:1.18.28' + annotationProcessor 'org.projectlombok:lombok:1.18.28' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + + implementation 'org.springframework.boot:spring-boot-starter-jdbc' + runtimeOnly 'com.h2database:h2' } test { useJUnitPlatform() } + diff --git a/src/main/java/roomescape/MainPageController.java b/src/main/java/roomescape/MainPageController.java new file mode 100644 index 000000000..7fe55776f --- /dev/null +++ b/src/main/java/roomescape/MainPageController.java @@ -0,0 +1,18 @@ +package roomescape; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class MainPageController { + + @GetMapping("/") + public String showHomePage() { + return "home"; + } + + @GetMapping("/reservation") + public String showReservationForm() { + return "reservation"; + } +} diff --git a/src/main/java/roomescape/RoomescapeApplication.java b/src/main/java/roomescape/RoomescapeApplication.java index 702706791..2ca0f743f 100644 --- a/src/main/java/roomescape/RoomescapeApplication.java +++ b/src/main/java/roomescape/RoomescapeApplication.java @@ -8,5 +8,4 @@ public class RoomescapeApplication { public static void main(String[] args) { SpringApplication.run(RoomescapeApplication.class, args); } - } diff --git a/src/main/java/roomescape/reservation/business/ReservationService.java b/src/main/java/roomescape/reservation/business/ReservationService.java new file mode 100644 index 000000000..a244beaf5 --- /dev/null +++ b/src/main/java/roomescape/reservation/business/ReservationService.java @@ -0,0 +1,33 @@ +package roomescape.reservation.business; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.presentation.dto.ReservationDto; +import roomescape.reservation.persistence.ReservationRepository; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ReservationService { + + private final ReservationRepository repository; + + public Reservation addReservation(ReservationDto reservationDto) { + Reservation reservation = convertToEntity(reservationDto); + return repository.save(reservation); + } + + public List checkReservations() { + return repository.findAll(); + } + + public void deleteReservation(Long reservationId) { + repository.delete(reservationId); + } + + private Reservation convertToEntity(ReservationDto reservationDto) { + return new Reservation(null, reservationDto.name(), reservationDto.date(), reservationDto.time()); + } +} diff --git a/src/main/java/roomescape/reservation/domain/Reservation.java b/src/main/java/roomescape/reservation/domain/Reservation.java new file mode 100644 index 000000000..65b4ffbce --- /dev/null +++ b/src/main/java/roomescape/reservation/domain/Reservation.java @@ -0,0 +1,26 @@ +package roomescape.reservation.domain; + +import lombok.Getter; + +@Getter +public class Reservation { + + private Long id; + private String name; + private String date; + private String time; + + public Reservation() { + } + + public Reservation(Long id, String name, String date, String time) { + this.id = id; + this.name = name; + this.date = date; + this.time = time; + } + + public void setId(Long id) { + this.id = id; + } +} diff --git a/src/main/java/roomescape/reservation/persistence/JdbcReservationRepository.java b/src/main/java/roomescape/reservation/persistence/JdbcReservationRepository.java new file mode 100644 index 000000000..20d29211a --- /dev/null +++ b/src/main/java/roomescape/reservation/persistence/JdbcReservationRepository.java @@ -0,0 +1,77 @@ +package roomescape.reservation.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.presentation.exception.NotFoundReservationException; + +import java.sql.PreparedStatement; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class JdbcReservationRepository implements ReservationRepository { + + private final JdbcTemplate jdbcTemplate; + + @Override + public Reservation save(Reservation reservation) { + String sql = "insert into reservation (name, date, time) values (?,?,?)"; + + KeyHolder keyHolder = new GeneratedKeyHolder(); + + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"}); + ps.setString(1, reservation.getName()); + ps.setString(2, reservation.getDate()); + ps.setString(3, reservation.getTime()); + return ps; + }, keyHolder); + + Long generatedAutoId = keyHolder.getKey().longValue(); + return new Reservation(generatedAutoId, reservation.getName(), reservation.getDate(), reservation.getTime()); + } + + @Override + public Reservation findById(Long reservationId) { + String sql = "select id, name, date, time from reservation where id = ?"; + + try { + return jdbcTemplate.queryForObject(sql, reservationMapper(), reservationId); + } catch (EmptyResultDataAccessException e) { + throw new NotFoundReservationException(); + } + } + + @Override + public List findAll() { + String sql = "select id, name, date, time from reservation"; + + return jdbcTemplate.query(sql, reservationMapper()); + } + + @Override + public void delete(Long reservationId) { + Reservation deletedReservation = this.findById(reservationId); + + String sql = "delete from reservation where id = ?"; + jdbcTemplate.update(sql, deletedReservation.getId()); + + } + + private RowMapper reservationMapper() { + return ((rs, rowNum) -> { + return new Reservation( + rs.getLong("id"), + rs.getString("name"), + rs.getString("date"), + rs.getString("time") + ); + }); + } +} diff --git a/src/main/java/roomescape/reservation/persistence/MemoryReservationRepository.java b/src/main/java/roomescape/reservation/persistence/MemoryReservationRepository.java new file mode 100644 index 000000000..32cabf2e1 --- /dev/null +++ b/src/main/java/roomescape/reservation/persistence/MemoryReservationRepository.java @@ -0,0 +1,46 @@ +package roomescape.reservation.persistence; + +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.presentation.exception.NotFoundReservationException; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +public class MemoryReservationRepository implements ReservationRepository { + + private static Map reservationStore = new ConcurrentHashMap<>(); + private static AtomicLong index = new AtomicLong(1); + + @Override + public Reservation save(Reservation reservation) { + + Long reservationId = index.getAndIncrement(); + reservation.setId(reservationId); + reservationStore.put(reservationId, reservation); + + return reservation; + } + + @Override + public Reservation findById(Long reservationId) { + Reservation reservation = reservationStore.get(reservationId); + if (reservation == null) { + throw new NotFoundReservationException(); + } + return reservation; + } + + @Override + public List findAll() { + return new ArrayList<>(reservationStore.values()); + } + + @Override + public void delete(Long reservationId) { + Reservation deletedReservation = this.findById(reservationId); + reservationStore.remove(deletedReservation.getId()); + } +} diff --git a/src/main/java/roomescape/reservation/persistence/ReservationRepository.java b/src/main/java/roomescape/reservation/persistence/ReservationRepository.java new file mode 100644 index 000000000..d0a7af0be --- /dev/null +++ b/src/main/java/roomescape/reservation/persistence/ReservationRepository.java @@ -0,0 +1,17 @@ +package roomescape.reservation.persistence; + +import roomescape.reservation.domain.Reservation; + +import java.util.List; + +public interface ReservationRepository { + + Reservation save(Reservation reservation); + + Reservation findById(Long reservationId); + + List findAll(); + + void delete(Long reservationId); + +} diff --git a/src/main/java/roomescape/reservation/presentation/ReservationController.java b/src/main/java/roomescape/reservation/presentation/ReservationController.java new file mode 100644 index 000000000..8e1247142 --- /dev/null +++ b/src/main/java/roomescape/reservation/presentation/ReservationController.java @@ -0,0 +1,36 @@ +package roomescape.reservation.presentation; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import roomescape.reservation.domain.Reservation; +import roomescape.reservation.presentation.dto.ReservationDto; +import roomescape.reservation.business.ReservationService; + +import java.net.URI; +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationService reservationService; + + @GetMapping("/reservations") + public ResponseEntity> readReservation() { + return ResponseEntity.ok(reservationService.checkReservations()); + } + + @PostMapping("/reservations") + public ResponseEntity createReservation(@Valid @RequestBody ReservationDto reservationDto) { + Reservation savedReservation = reservationService.addReservation(reservationDto); + return ResponseEntity.created(URI.create("/reservations/" + savedReservation.getId())).body(savedReservation); + } + + @DeleteMapping("/reservations/{reservationId}") + public ResponseEntity deleteReservation(@PathVariable Long reservationId) { + reservationService.deleteReservation(reservationId); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/roomescape/reservation/presentation/dto/ReservationDto.java b/src/main/java/roomescape/reservation/presentation/dto/ReservationDto.java new file mode 100644 index 000000000..60e5e62a3 --- /dev/null +++ b/src/main/java/roomescape/reservation/presentation/dto/ReservationDto.java @@ -0,0 +1,10 @@ +package roomescape.reservation.presentation.dto; + +import jakarta.validation.constraints.NotBlank; + +public record ReservationDto( + @NotBlank(message = "예약자 이름을 입력하세요.") String name, + @NotBlank(message = "예약 날짜를 입력하세요.") String date, + @NotBlank(message = "예약 시간을 입력하세요.") String time +) { +} diff --git a/src/main/java/roomescape/reservation/presentation/exception/MainPageExceptionHandler.java b/src/main/java/roomescape/reservation/presentation/exception/MainPageExceptionHandler.java new file mode 100644 index 000000000..66c2caa68 --- /dev/null +++ b/src/main/java/roomescape/reservation/presentation/exception/MainPageExceptionHandler.java @@ -0,0 +1,16 @@ +package roomescape.reservation.presentation.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import roomescape.MainPageController; + +@Slf4j +@ControllerAdvice(assignableTypes = MainPageController.class) +public class MainPageExceptionHandler { + @ExceptionHandler(Exception.class) + public String handleException(Exception e) { + log.error("error: " + e.getMessage()); + return "error/500"; //view 렌더링 페이지는 만들지 않음! + } +} diff --git a/src/main/java/roomescape/reservation/presentation/exception/NotFoundReservationException.java b/src/main/java/roomescape/reservation/presentation/exception/NotFoundReservationException.java new file mode 100644 index 000000000..ef6a65877 --- /dev/null +++ b/src/main/java/roomescape/reservation/presentation/exception/NotFoundReservationException.java @@ -0,0 +1,10 @@ +package roomescape.reservation.presentation.exception; + +public class NotFoundReservationException extends RuntimeException { + + private static final String NOT_FOUND_RESERVATION_MESSAGE = "예악을 찾을 수 없습니다."; + + public NotFoundReservationException() { + super(NOT_FOUND_RESERVATION_MESSAGE); + } +} diff --git a/src/main/java/roomescape/reservation/presentation/exception/ReservationExceptionHandler.java b/src/main/java/roomescape/reservation/presentation/exception/ReservationExceptionHandler.java new file mode 100644 index 000000000..646475d2e --- /dev/null +++ b/src/main/java/roomescape/reservation/presentation/exception/ReservationExceptionHandler.java @@ -0,0 +1,39 @@ +package roomescape.reservation.presentation.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import roomescape.reservation.presentation.ReservationController; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@ControllerAdvice(assignableTypes = ReservationController.class) +public class ReservationExceptionHandler { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException e) { + Map errors = new HashMap<>(); + + for (FieldError error : e.getBindingResult().getFieldErrors()) { + errors.put(error.getField(), error.getDefaultMessage()); + log.info("validation error on field {} : {}", error.getField(), error.getDefaultMessage()); + } + + return ResponseEntity.badRequest().body(errors); + } + + @ExceptionHandler(NotFoundReservationException.class) + public ResponseEntity handleNotFoundReservationException(NotFoundReservationException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + return ResponseEntity.internalServerError().body(e.getMessage()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index e69de29bb..d2b42566e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -0,0 +1,4 @@ +# h2-console ??? ?? +spring.h2.console.enabled=true +# db url +spring.datasource.url=jdbc:h2:mem:database diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 000000000..8d9ab2754 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,8 @@ +CREATE TABLE reservation +( + id BIGINT NOT NULL AUTO_INCREMENT, + name VARCHAR(255) NOT NULL, + date VARCHAR(255) NOT NULL, + time VARCHAR(255) NOT NULL, + PRIMARY KEY (id) +); diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java deleted file mode 100644 index cf4efbe91..000000000 --- a/src/test/java/roomescape/MissionStepTest.java +++ /dev/null @@ -1,19 +0,0 @@ -package roomescape; - -import io.restassured.RestAssured; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.annotation.DirtiesContext; - -@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) -@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) -public class MissionStepTest { - - @Test - void 일단계() { - RestAssured.given().log().all() - .when().get("/") - .then().log().all() - .statusCode(200); - } -} diff --git a/src/test/java/roomescape/SpringJDBCMissionStepTest.java b/src/test/java/roomescape/SpringJDBCMissionStepTest.java new file mode 100644 index 000000000..14a8393c0 --- /dev/null +++ b/src/test/java/roomescape/SpringJDBCMissionStepTest.java @@ -0,0 +1,80 @@ +package roomescape; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import roomescape.reservation.domain.Reservation; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +public class SpringJDBCMissionStepTest { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void 오단계() { + try (Connection connection = jdbcTemplate.getDataSource().getConnection()) { + assertThat(connection).isNotNull(); + assertThat(connection.getCatalog()).isEqualTo("DATABASE"); + assertThat(connection.getMetaData().getTables(null, null, "RESERVATION", null).next()).isTrue(); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @Test + void 육단계() { + jdbcTemplate.update("INSERT INTO reservation (name, date, time) VALUES (?, ?, ?)", "브라운", "2023-08-05", "15:40"); + + List reservations = RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200).extract() + .jsonPath().getList(".", Reservation.class); + + Integer count = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class); + + assertThat(reservations.size()).isEqualTo(count); + } + + @Test + void 칠단계() { + Map params = new HashMap<>(); + params.put("name", "브라운"); + params.put("date", "2023-08-05"); + params.put("time", "10:00"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/reservations") + .then().log().all() + .statusCode(201) + .header("Location", "/reservations/1"); + + Integer count = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class); + assertThat(count).isEqualTo(1); + + RestAssured.given().log().all() + .when().delete("/reservations/1") + .then().log().all() + .statusCode(204); + + Integer countAfterDelete = jdbcTemplate.queryForObject("SELECT count(1) from reservation", Integer.class); + assertThat(countAfterDelete).isEqualTo(0); + } + +} diff --git a/src/test/java/roomescape/SpringMVCMissionStepTest.java b/src/test/java/roomescape/SpringMVCMissionStepTest.java new file mode 100644 index 000000000..b306b58a7 --- /dev/null +++ b/src/test/java/roomescape/SpringMVCMissionStepTest.java @@ -0,0 +1,96 @@ +package roomescape; + +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import java.util.HashMap; +import java.util.Map; + +import static org.hamcrest.Matchers.is; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD) +public class SpringMVCMissionStepTest { + + @Test + void 일단계() { + RestAssured.given().log().all() + .when().get("/") + .then().log().all() + .statusCode(200); + } + + @Test + void 이단계() { + RestAssured.given().log().all() + .when().get("/reservation") + .then().log().all() + .statusCode(200); + + RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("size()", is(0)); // 아직 생성 요청이 없으니 Controller에서 임의로 넣어준 Reservation 갯수 만큼 검증하거나 0개임을 확인하세요. + } + + @Test + void 삼단계() { + Map params = new HashMap<>(); + params.put("name", "브라운"); + params.put("date", "2023-08-05"); + params.put("time", "15:40"); + + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/reservations") + .then().log().all() + .statusCode(201) + .header("Location", "/reservations/1") + .body("id", is(1)); + + RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("size()", is(1)); + + RestAssured.given().log().all() + .when().delete("/reservations/1") + .then().log().all() + .statusCode(204); + + RestAssured.given().log().all() + .when().get("/reservations") + .then().log().all() + .statusCode(200) + .body("size()", is(0)); + } + + @Test + void 사단계() { + Map params = new HashMap<>(); + params.put("name", "브라운"); + params.put("date", ""); + params.put("time", ""); + + // 필요한 인자가 없는 경우 + RestAssured.given().log().all() + .contentType(ContentType.JSON) + .body(params) + .when().post("/reservations") + .then().log().all() + .statusCode(400); + + // 삭제할 예약이 없는 경우 + RestAssured.given().log().all() + .when().delete("/reservations/1") + .then().log().all() + .statusCode(400); + } + +}