diff --git a/build.gradle b/build.gradle index 57267157c..0d8d21264 100644 --- a/build.gradle +++ b/build.gradle @@ -13,7 +13,9 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-web' + 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:5.3.1' } 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/controller/HomeController.java b/src/main/java/roomescape/controller/HomeController.java new file mode 100644 index 000000000..7ee555676 --- /dev/null +++ b/src/main/java/roomescape/controller/HomeController.java @@ -0,0 +1,12 @@ +package roomescape.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + @GetMapping("/") + public String home() { + return "home"; + } +} diff --git a/src/main/java/roomescape/controller/ReservationController.java b/src/main/java/roomescape/controller/ReservationController.java new file mode 100644 index 000000000..09935f24c --- /dev/null +++ b/src/main/java/roomescape/controller/ReservationController.java @@ -0,0 +1,63 @@ +package roomescape.controller; + +import jakarta.validation.Valid; +import java.net.URI; +import java.util.List; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import roomescape.dto.ReservationCreateRequest; +import roomescape.dto.ReservationCreateResponse; +import roomescape.model.Reservation; +import roomescape.service.ReservationService; + +@Controller +public class ReservationController { + private final ReservationService reservationService; + + public ReservationController(ReservationService reservationService) { + this.reservationService = reservationService; + } + + /* + * View Mapping + */ + + @GetMapping("/reservation") + public String reservation() { + return "reservation"; + } + + /* + * API Mapping + */ + + @ResponseBody + @GetMapping("/reservations") + public List getReservations() { + return reservationService.getReservations(); + } + + @ResponseBody + @PostMapping("/reservations") + public ResponseEntity createReservation(@RequestBody @Valid ReservationCreateRequest request) { + Reservation reservation = reservationService.createReservation(request); + + URI location = URI.create("/reservations/" + reservation.id()); + return ResponseEntity.created(location).body(ReservationCreateResponse.from(reservation)); + } + + @ResponseBody + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/reservations/{id}") + public void deleteReservation(@PathVariable int id) { + reservationService.deleteReservation(id); + } +} diff --git a/src/main/java/roomescape/dto/ErrorResponse.java b/src/main/java/roomescape/dto/ErrorResponse.java new file mode 100644 index 000000000..1b055bf1a --- /dev/null +++ b/src/main/java/roomescape/dto/ErrorResponse.java @@ -0,0 +1,3 @@ +package roomescape.dto; + +public record ErrorResponse(String message) { } diff --git a/src/main/java/roomescape/dto/ReservationCreateRequest.java b/src/main/java/roomescape/dto/ReservationCreateRequest.java new file mode 100644 index 000000000..0da21de63 --- /dev/null +++ b/src/main/java/roomescape/dto/ReservationCreateRequest.java @@ -0,0 +1,19 @@ +package roomescape.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import java.time.LocalTime; +import roomescape.validation.ValidDate; +import roomescape.validation.ValidTime; + +public record ReservationCreateRequest( + @NotBlank(message = "이름은 공백일 수 없습니다.") + String name, + + @ValidDate(message = "날짜 형식이 올바르지 않습니다. 예: yyyy-MM-dd") + String date, + + @ValidTime(message = "시간 형식이 올바르지 않습니다. 예: HH:mm") + String time +) { } diff --git a/src/main/java/roomescape/dto/ReservationCreateResponse.java b/src/main/java/roomescape/dto/ReservationCreateResponse.java new file mode 100644 index 000000000..973574adc --- /dev/null +++ b/src/main/java/roomescape/dto/ReservationCreateResponse.java @@ -0,0 +1,12 @@ +package roomescape.dto; + +import java.time.LocalDate; +import java.time.LocalTime; +import roomescape.model.Reservation; + +public record ReservationCreateResponse(int id, String name, LocalDate date, LocalTime time) { + public static ReservationCreateResponse from(Reservation reservation) { + return new ReservationCreateResponse(reservation.id(), reservation.name(), reservation.date(), reservation.time()); + } +} + diff --git a/src/main/java/roomescape/exception/GlobalExceptionHandler.java b/src/main/java/roomescape/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..b4462ecab --- /dev/null +++ b/src/main/java/roomescape/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package roomescape.exception; + +import com.fasterxml.jackson.databind.exc.InvalidFormatException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import roomescape.dto.ErrorResponse; + +@ControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(ReservationNotFoundException.class) + public ResponseEntity handleReservationNotFoundException(ReservationNotFoundException e) { + return new ResponseEntity<>(new ErrorResponse(e.getMessage()), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(ReservationValidationException.class) + public ResponseEntity handleReservationValidationException(ReservationValidationException e) { + return new ResponseEntity<>(new ErrorResponse(e.getMessage()), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getAllErrors().stream().map((err) -> err.getDefaultMessage()).findFirst().orElse("입력 값 검증 실패"); + + return new ResponseEntity<>(new ErrorResponse(message), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleFallbackException() { + return new ResponseEntity<>(new ErrorResponse("내부 서버 오류가 발생했습니다."), HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/src/main/java/roomescape/exception/ReservationNotFoundException.java b/src/main/java/roomescape/exception/ReservationNotFoundException.java new file mode 100644 index 000000000..a101dd0f7 --- /dev/null +++ b/src/main/java/roomescape/exception/ReservationNotFoundException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class ReservationNotFoundException extends RuntimeException { + public ReservationNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/ReservationValidationException.java b/src/main/java/roomescape/exception/ReservationValidationException.java new file mode 100644 index 000000000..c06b632e6 --- /dev/null +++ b/src/main/java/roomescape/exception/ReservationValidationException.java @@ -0,0 +1,7 @@ +package roomescape.exception; + +public class ReservationValidationException extends RuntimeException { + public ReservationValidationException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/model/Reservation.java b/src/main/java/roomescape/model/Reservation.java new file mode 100644 index 000000000..3c9b16f40 --- /dev/null +++ b/src/main/java/roomescape/model/Reservation.java @@ -0,0 +1,6 @@ +package roomescape.model; + +import java.time.LocalDate; +import java.time.LocalTime; + +public record Reservation(int id, String name, LocalDate date, LocalTime time) { } diff --git a/src/main/java/roomescape/service/ReservationService.java b/src/main/java/roomescape/service/ReservationService.java new file mode 100644 index 000000000..8490b0c44 --- /dev/null +++ b/src/main/java/roomescape/service/ReservationService.java @@ -0,0 +1,55 @@ +package roomescape.service; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import roomescape.dto.ReservationCreateRequest; +import roomescape.exception.ReservationNotFoundException; +import roomescape.exception.ReservationValidationException; +import roomescape.model.Reservation; + +@Service +public class ReservationService { + private final AtomicInteger id; + private final List reservations; + + public ReservationService() { + this.id = new AtomicInteger(0); + this.reservations = Collections.synchronizedList(new ArrayList<>()); + +// populateDefaults(); + } + + public List getReservations() { + return reservations; + } + + public Reservation createReservation(ReservationCreateRequest request) { + LocalDate date = LocalDate.parse(request.date()); + LocalTime time = LocalTime.parse(request.time()); + + Reservation reservation = new Reservation(id.incrementAndGet(), request.name(), date, time); + reservations.add(reservation); + + return reservation; + } + + public void deleteReservation(int id) { + reservations.removeIf((reservation -> reservation.id() == id)); + } + + private void populateDefaults() { + reservations.addAll( + List.of( + new Reservation(id.incrementAndGet(), "브라운", LocalDate.parse("2025-01-01"), LocalTime.parse("10:00")), + new Reservation(id.incrementAndGet(), "브라운", LocalDate.parse("2025-01-02"), LocalTime.parse("11:00")), + new Reservation(id.incrementAndGet(), "브라운", LocalDate.parse("2025-01-03"), LocalTime.parse("12:00")) + ) + ); + } +} diff --git a/src/main/java/roomescape/validation/DateValidator.java b/src/main/java/roomescape/validation/DateValidator.java new file mode 100644 index 000000000..089fcc1c1 --- /dev/null +++ b/src/main/java/roomescape/validation/DateValidator.java @@ -0,0 +1,23 @@ +package roomescape.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import org.springframework.util.StringUtils; + +public class DateValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (!StringUtils.hasText(value)) { + return false; + } + + try { + LocalDate.parse(value); + return true; + } catch (DateTimeParseException e) { + return false; + } + } +} diff --git a/src/main/java/roomescape/validation/TimeValidator.java b/src/main/java/roomescape/validation/TimeValidator.java new file mode 100644 index 000000000..a7f0230c8 --- /dev/null +++ b/src/main/java/roomescape/validation/TimeValidator.java @@ -0,0 +1,23 @@ +package roomescape.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import java.time.LocalTime; +import java.time.format.DateTimeParseException; +import org.springframework.util.StringUtils; + +public class TimeValidator implements ConstraintValidator { + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (!StringUtils.hasText(value)) { + return false; + } + + try { + LocalTime.parse(value); + return true; + } catch (DateTimeParseException e) { + return false; + } + } +} diff --git a/src/main/java/roomescape/validation/ValidDate.java b/src/main/java/roomescape/validation/ValidDate.java new file mode 100644 index 000000000..4bd12c873 --- /dev/null +++ b/src/main/java/roomescape/validation/ValidDate.java @@ -0,0 +1,17 @@ +package roomescape.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = DateValidator.class) +public @interface ValidDate { + String message() default "잘못된 날짜 형식입니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/roomescape/validation/ValidTime.java b/src/main/java/roomescape/validation/ValidTime.java new file mode 100644 index 000000000..2a4d45b7c --- /dev/null +++ b/src/main/java/roomescape/validation/ValidTime.java @@ -0,0 +1,17 @@ +package roomescape.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = TimeValidator.class) +public @interface ValidTime { + String message() default "잘못된 시간 형식입니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java index cf4efbe91..4ada84317 100644 --- a/src/test/java/roomescape/MissionStepTest.java +++ b/src/test/java/roomescape/MissionStepTest.java @@ -1,12 +1,18 @@ package roomescape; +import static org.hamcrest.Matchers.is; + import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import java.util.HashMap; +import java.util.Map; 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) +@SuppressWarnings("NonAsciiCharacters") public class MissionStepTest { @Test @@ -16,4 +22,74 @@ public class MissionStepTest { .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)); + } + + @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); + } }