diff --git a/src/main/java/com/board/controller/BlogApiController.java b/src/main/java/com/board/controller/BlogApiController.java new file mode 100644 index 0000000..f1726b4 --- /dev/null +++ b/src/main/java/com/board/controller/BlogApiController.java @@ -0,0 +1,78 @@ +package com.board.controller; + +import com.board.domain.Article; +import com.board.dto.ArticleCreateRequest; +import com.board.dto.ArticleResponse; +import com.board.dto.ArticleUpdateRequest; +import com.board.service.BlogService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/articles") +public class BlogApiController { + + public final BlogService blogService; + + @PostMapping("") + public ResponseEntity addArticle(@RequestBody ArticleCreateRequest request) { + Article savedArticle = blogService.save(request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(new ArticleResponse(savedArticle)); + } + + @GetMapping("") + public ResponseEntity> findAllArticles(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + + Pageable pageable = PageRequest.of(page, size); + + List articles = blogService.findAll(pageable) + .stream() + .map(ArticleResponse::new) + .toList(); + + return ResponseEntity.ok() + .body(articles); + } + + @GetMapping("/{id}") + public ResponseEntity findArticle(@PathVariable long id) { + Article article = blogService.findById(id); + + return ResponseEntity.ok() + .body(new ArticleResponse(article)); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteArticle(@PathVariable long id) { + blogService.delete(id); + + return ResponseEntity.noContent() + .build(); + } + + @PutMapping("/{id}") + public ResponseEntity updateArticle(@PathVariable long id, + @RequestBody ArticleUpdateRequest request) { + Article updateArticle = blogService.update(id, request); + + return ResponseEntity.ok() + .body(new ArticleResponse(updateArticle)); + } +} diff --git a/src/main/java/com/board/domain/Article.java b/src/main/java/com/board/domain/Article.java new file mode 100644 index 0000000..8615e63 --- /dev/null +++ b/src/main/java/com/board/domain/Article.java @@ -0,0 +1,38 @@ +package com.board.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class Article { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", updatable = false) + private Long id; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", nullable = false) + private String content; + + @Builder + public Article(String title, String content) { + this.title = title; + this.content = content; + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/com/board/dto/ArticleCreateRequest.java b/src/main/java/com/board/dto/ArticleCreateRequest.java new file mode 100644 index 0000000..e30b72e --- /dev/null +++ b/src/main/java/com/board/dto/ArticleCreateRequest.java @@ -0,0 +1,20 @@ +package com.board.dto; + +import com.board.domain.Article; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ArticleCreateRequest { + + private final String title; + private final String content; + + public Article toEntity() { + return Article.builder() + .title(title) + .content(content) + .build(); + } +} diff --git a/src/main/java/com/board/dto/ArticleResponse.java b/src/main/java/com/board/dto/ArticleResponse.java new file mode 100644 index 0000000..2ff6fdb --- /dev/null +++ b/src/main/java/com/board/dto/ArticleResponse.java @@ -0,0 +1,18 @@ +package com.board.dto; + +import com.board.domain.Article; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter +public class ArticleResponse { + + private final String title; + private final String content; + + public ArticleResponse(Article article) { + this.title = article.getTitle(); + this.content = article.getContent(); + } +} diff --git a/src/main/java/com/board/dto/ArticleUpdateRequest.java b/src/main/java/com/board/dto/ArticleUpdateRequest.java new file mode 100644 index 0000000..387d0fd --- /dev/null +++ b/src/main/java/com/board/dto/ArticleUpdateRequest.java @@ -0,0 +1,12 @@ +package com.board.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ArticleUpdateRequest { + + private final String title; + private final String content; +} diff --git a/src/main/java/com/board/exception/ErrorCode.java b/src/main/java/com/board/exception/ErrorCode.java new file mode 100644 index 0000000..889387d --- /dev/null +++ b/src/main/java/com/board/exception/ErrorCode.java @@ -0,0 +1,23 @@ +package com.board.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ErrorCode { + ENTITY_NOT_FOUND("ENTITY_NOT_FOUND", "엔티티를 찾을 수 없습니다", HttpStatus.NOT_FOUND), + VALIDATION_ERROR("VALIDATION_ERROR", "유효성 검증 실패", HttpStatus.BAD_REQUEST), + AUTHENTICATION_ERROR("AUTHENTICATION_ERROR", "인증 실패", HttpStatus.UNAUTHORIZED), + AUTHORIZATION_ERROR("AUTHORIZATION_ERROR", "권한 없음", HttpStatus.FORBIDDEN), + SYSTEM_ERROR("SYSTEM_ERROR", "시스템 오류", HttpStatus.INTERNAL_SERVER_ERROR); + + private final String code; + private final String message; + private final HttpStatus httpStatus; + + ErrorCode(String code, String message, HttpStatus httpStatus) { + this.code = code; + this.message = message; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/board/exception/ErrorResponse.java b/src/main/java/com/board/exception/ErrorResponse.java new file mode 100644 index 0000000..405c66c --- /dev/null +++ b/src/main/java/com/board/exception/ErrorResponse.java @@ -0,0 +1,40 @@ +package com.board.exception; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import lombok.Builder; +import lombok.Getter; + +@Getter +class ErrorResponse { + private int statusCode; + private LocalDateTime timestamp; + private String code; + private String message; + private Map details; + + @Builder + public ErrorResponse(int statusCode, LocalDateTime timestamp, String code, String message) { + this.statusCode = statusCode; + this.timestamp = timestamp; + this.code = code; + this.message = message; + } + + public static ErrorResponse of(ErrorCode errorCode) { + return builder() + .statusCode(errorCode.getHttpStatus().value()) + .timestamp(LocalDateTime.now()) + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + } + + public void addDetail(String key, Object value) { + if (details == null) { + details = new HashMap<>(); + } + this.details.put(key, value); + } +} diff --git a/src/main/java/com/board/exception/GlobalExceptionHandler.java b/src/main/java/com/board/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d168ed9 --- /dev/null +++ b/src/main/java/com/board/exception/GlobalExceptionHandler.java @@ -0,0 +1,27 @@ +package com.board.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + @ExceptionHandler(MyEntityNotFoundException.class) + public ResponseEntity handleEntityNotFound(MyEntityNotFoundException e) { + + log.error("Error Code: {}, Message: {}, entityId: {}", + e.getErrorCode().getCode(), + e.getErrorCode().getMessage(), + e.getEntityId(), + e); + + ErrorResponse errorResponse = ErrorResponse.of(e.getErrorCode()); + errorResponse.addDetail("entityId : ", e.getEntityId()); + + return new ResponseEntity<>(errorResponse, e.getErrorCode().getHttpStatus()); + } + +} diff --git a/src/main/java/com/board/exception/MyEntityNotFoundException.java b/src/main/java/com/board/exception/MyEntityNotFoundException.java new file mode 100644 index 0000000..02cac99 --- /dev/null +++ b/src/main/java/com/board/exception/MyEntityNotFoundException.java @@ -0,0 +1,21 @@ +package com.board.exception; + +import lombok.Getter; + +@Getter +public class MyEntityNotFoundException extends RuntimeException { + private final ErrorCode errorCode; + private final long entityId; + + private MyEntityNotFoundException(ErrorCode errorCode, long entityId) { + this.errorCode = errorCode; + this.entityId = entityId; + } + + public static MyEntityNotFoundException of(long entityId) { + return new MyEntityNotFoundException( + ErrorCode.ENTITY_NOT_FOUND, + entityId + ); + } +} diff --git a/src/main/java/com/board/repository/BlogRepository.java b/src/main/java/com/board/repository/BlogRepository.java new file mode 100644 index 0000000..afc6c03 --- /dev/null +++ b/src/main/java/com/board/repository/BlogRepository.java @@ -0,0 +1,7 @@ +package com.board.repository; + +import com.board.domain.Article; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BlogRepository extends JpaRepository { +} diff --git a/src/main/java/com/board/service/BlogService.java b/src/main/java/com/board/service/BlogService.java new file mode 100644 index 0000000..0bfe029 --- /dev/null +++ b/src/main/java/com/board/service/BlogService.java @@ -0,0 +1,50 @@ +package com.board.service; + +import com.board.domain.Article; +import com.board.dto.ArticleCreateRequest; +import com.board.dto.ArticleUpdateRequest; +import com.board.exception.MyEntityNotFoundException; +import com.board.repository.BlogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional +public class BlogService { + + private final BlogRepository blogRepository; + + public Article save(ArticleCreateRequest request) { + return blogRepository.save(request.toEntity()); + } + + @Transactional(readOnly = true) + public Page
findAll(Pageable pageable) { + return blogRepository.findAll(pageable); + } + + @Transactional(readOnly = true) + public Article findById(long id) { + return findArticle(id); + } + + public void delete(long id) { + blogRepository.deleteById(id); + } + + public Article update(long id, ArticleUpdateRequest request) { + Article article = findArticle(id); + + article.update(request.getTitle(), request.getContent()); + return article; + } + + private Article findArticle(long id) { + return blogRepository.findById(id) + .orElseThrow(() -> MyEntityNotFoundException.of(id)); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index efe2ab5..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=board diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..5a2fc73 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,22 @@ +server: + servlet: + context-path: /api + +spring: + application: + name: board + + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + + defer_datasource-initialization: true #data.sql + + datasource: + url: jdbc:h2:mem:testdb + + h2: + console: + enabled: true diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql new file mode 100644 index 0000000..d166e34 --- /dev/null +++ b/src/main/resources/data.sql @@ -0,0 +1,3 @@ +INSERT INTO article (title, content) VALUES ('제목 1', '내용 1') +INSERT INTO article (title, content) VALUES ('제목 2', '내용 2') +INSERT INTO article (title, content) VALUES ('제목 3', '내용 3') diff --git a/src/test/java/com/board/controller/BlogApiControllerTest.java b/src/test/java/com/board/controller/BlogApiControllerTest.java new file mode 100644 index 0000000..fa44248 --- /dev/null +++ b/src/test/java/com/board/controller/BlogApiControllerTest.java @@ -0,0 +1,180 @@ +package com.board.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.board.domain.Article; +import com.board.dto.ArticleCreateRequest; +import com.board.dto.ArticleUpdateRequest; +import com.board.repository.BlogRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = "server.servlet.context-path=/api") +class BlogApiControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private WebApplicationContext context; + + @Autowired + BlogRepository blogRepository; + + @BeforeEach + public void mockMvcSetup() { + this.mockMvc = MockMvcBuilders.webAppContextSetup(context) + .build(); + blogRepository.deleteAll(); + } + + @DisplayName("addArticle: 블로그 글 추가에 성공한다.") + @Test + public void addArticle() throws Exception { + // given + final String url = "/articles"; + final String title = "title123"; + final String content = "content123"; + final ArticleCreateRequest userRequest = new ArticleCreateRequest(title, content); + final String requestBody = objectMapper.writeValueAsString(userRequest); + + // when + ResultActions result = mockMvc.perform(post(url) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(requestBody)); + + // then + result.andExpect(status().isCreated()); + + List
articles = blogRepository.findAll(); + + Assertions.assertThat(articles.size()).isEqualTo(1); + Assertions.assertThat(articles.get(0).getTitle()).isEqualTo(title); + Assertions.assertThat(articles.get(0).getContent()).isEqualTo(content); + } + + @DisplayName("findAllArticles: 블로그 글 전체 조회에 성공한다") + @Test + public void findAllArticles() throws Exception { + // given + String url = "/articles"; + String title = "title1"; + String content = "content1"; + + blogRepository.save(Article.builder() + .title(title) + .content(content) + .build()); + + // when + ResultActions result = mockMvc.perform(get(url) + .accept(MediaType.APPLICATION_JSON)); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value(title)) + .andExpect(jsonPath("$[0].content").value(content)); + } + + @DisplayName("findArticle: 블로그 글 조회에 성공한다") + @Test + public void findArticle() throws Exception { + // given + String url = "/articles/{id}"; + String title = "title1"; + String content = "content1"; + + Article article = blogRepository.save(Article.builder() + .title(title) + .content(content) + .build()); + + // when + ResultActions result = mockMvc.perform(get(url, article.getId()) + .accept(MediaType.APPLICATION_JSON)); + + // then + result + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value(title)) + .andExpect(jsonPath("$.content").value(content)); + } + + @DisplayName("deleteArticle : 블로그 글 삭제에 성공한다") + @Test + void deleteArticle() throws Exception { + // given + String url = "/articles/{id}"; + String title = "title1"; + String content = "content1"; + + Article savedArticle = blogRepository.save(Article.builder() + .title(title) + .content(content) + .build()); + + // when + mockMvc.perform(delete(url, savedArticle.getId())) + .andExpect(status().isNoContent()); + + // then + List
articles = blogRepository.findAll(); + Assertions.assertThat(articles).isEmpty(); + } + + @DisplayName("updateArticle : 글 수정에 성공한다") + @Test + void updateArticle() throws Exception { + // given + String url = "/articles/{id}"; + String title = "title1"; + String content = "content1"; + + Article savedArticle = blogRepository.save(Article.builder() + .title(title) + .content(content) + .build()); + + String changedTitle = "aaaaTitle"; + String changedContent = "aaaaContent"; + + ArticleUpdateRequest requestDto = new ArticleUpdateRequest(changedTitle, changedContent); + + // when + ResultActions result = mockMvc.perform(put(url, savedArticle.getId()) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .content(objectMapper.writeValueAsString(requestDto))); + + // then + result.andExpect(status().isOk()); + + Article article = blogRepository.findById(savedArticle.getId()).get(); + + Assertions.assertThat(article.getTitle()).isEqualTo(changedTitle); + Assertions.assertThat(article.getContent()).isEqualTo(changedContent); + } +}