diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..fcfa360f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +gradlew +gradlew.bat +gradle +BOOT-INF + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..d95213b99 --- /dev/null +++ b/build.gradle @@ -0,0 +1,77 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.2' + id 'io.spring.dependency-management' version '1.1.2' + id "org.asciidoctor.jvm.convert" version "3.3.2" +} + +group = 'com.prgrms' +version = '0.0.1-SNAPSHOT' + +java { + sourceCompatibility = '17' +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +ext { + snippetsDir = file('build/generated-snippets') +} + +bootJar { + dependsOn asciidoctor + copy { + from "${asciidoctor.outputDir}" + into 'BOOT-INF/classes/static/docs' + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + implementation 'org.springframework.boot:spring-boot-starter-validation' + + implementation 'org.springframework.boot:spring-boot-starter-web' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + runtimeOnly 'com.h2database:h2' + + runtimeOnly 'com.mysql:mysql-connector-j' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' +} + +tasks.named('test') { + useJUnitPlatform() +} + +asciidoctor { + dependsOn test + inputs.dir snippetsDir +} + +asciidoctor.doFirst { + delete file('src/main/resources/static/docs') +} + +tasks.register('copyDocument', Copy) { + dependsOn asciidoctor + from file("build/docs/asciidoc") + into file("src/main/resources/static/docs") +} + +build { + dependsOn copyDocument +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..5f717a7cc --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'board' diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 000000000..4d685a0df --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,41 @@ += Board REST API + +== 게시물 생성 + +=== REQUEST + +include::{snippets}/posts/createPost/http-request.adoc[] + +=== RESPONSE + +include::{snippets}/posts/createPost/http-response.adoc[] + +== 게시물 전체 조회 + +=== REQUEST + +include::{snippets}/posts/getPosts/http-request.adoc[] + +=== RESPONSE + +include::{snippets}/posts/getPosts/http-response.adoc[] + +== 게시물 조회 + +=== REQUEST + +include::{snippets}/posts/getPost/http-request.adoc[] + +=== RESPONSE + +include::{snippets}/posts/getPost/http-response.adoc[] + +== 게시물 수정 + +=== REQUEST + +include::{snippets}/posts/updatePost/http-request.adoc[] + +=== RESPONSE + +include::{snippets}/posts/updatePost/http-response.adoc[] diff --git a/src/main/java/com/prgrms/board/BoardApplication.java b/src/main/java/com/prgrms/board/BoardApplication.java new file mode 100644 index 000000000..81231c991 --- /dev/null +++ b/src/main/java/com/prgrms/board/BoardApplication.java @@ -0,0 +1,12 @@ +package com.prgrms.board; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BoardApplication { + + public static void main(String[] args) { + SpringApplication.run(BoardApplication.class, args); + } +} diff --git a/src/main/java/com/prgrms/board/domain/post/controller/ApiPostController.java b/src/main/java/com/prgrms/board/domain/post/controller/ApiPostController.java new file mode 100644 index 000000000..2b5942b8f --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/controller/ApiPostController.java @@ -0,0 +1,63 @@ +package com.prgrms.board.domain.post.controller; + +import static com.prgrms.board.global.common.SuccessMessage.*; +import static org.springframework.http.HttpStatus.*; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.prgrms.board.domain.post.dto.request.PostCreateRequest; +import com.prgrms.board.domain.post.dto.request.PostUpdateRequest; +import com.prgrms.board.domain.post.dto.response.PostDetailResponse; +import com.prgrms.board.domain.post.dto.response.PostResponse; +import com.prgrms.board.domain.post.entity.Post; +import com.prgrms.board.domain.post.service.PostService; +import com.prgrms.board.global.common.dto.BaseResponse; +import com.prgrms.board.global.common.dto.PageResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/posts") +public class ApiPostController { + + private final PostService postService; + + @GetMapping + public BaseResponse> getPosts( + @PageableDefault(sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ) { + PageResponse data = postService.getPosts(pageable); + return BaseResponse.ok(GET_POSTS_SUCCESS, data); + } + + @GetMapping("/{postId}") + public BaseResponse getPost(@PathVariable Long postId) { + PostDetailResponse data = postService.getPost(postId); + return BaseResponse.ok(GET_POST_SUCCESS, data); + } + + @PostMapping + @ResponseStatus(CREATED) + public BaseResponse createPost(@Valid @RequestBody PostCreateRequest request) { + PostResponse data = postService.createPost(request); + return BaseResponse.created(CREATE_POST_SUCCESS, data); + } + + @PatchMapping("/{postId}") + public BaseResponse updatePost(@PathVariable Long postId, @RequestBody PostUpdateRequest request) { + PostResponse data = postService.updatePost(postId, request); + return BaseResponse.ok(UPDATE_POST_SUCCESS, data); + } +} diff --git a/src/main/java/com/prgrms/board/domain/post/dto/request/PostCreateRequest.java b/src/main/java/com/prgrms/board/domain/post/dto/request/PostCreateRequest.java new file mode 100644 index 000000000..8eeead708 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/dto/request/PostCreateRequest.java @@ -0,0 +1,24 @@ +package com.prgrms.board.domain.post.dto.request; + +import com.prgrms.board.domain.post.entity.Post; +import com.prgrms.board.domain.user.entity.User; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record PostCreateRequest( + @NotNull(message = "유저 아이디는 필수입니다.") + Long userId, + + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 30, message = "제목은 최대 30자까지 가능합니다.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + String content +) { + public Post toEntity(User user) { + return Post.create(user, title, content); + } +} diff --git a/src/main/java/com/prgrms/board/domain/post/dto/request/PostUpdateRequest.java b/src/main/java/com/prgrms/board/domain/post/dto/request/PostUpdateRequest.java new file mode 100644 index 000000000..d7f14e6e8 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/dto/request/PostUpdateRequest.java @@ -0,0 +1,14 @@ +package com.prgrms.board.domain.post.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record PostUpdateRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 30, message = "제목은 최대 30자까지 가능합니다.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + String content +) { +} diff --git a/src/main/java/com/prgrms/board/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/prgrms/board/domain/post/dto/response/PostDetailResponse.java new file mode 100644 index 000000000..b6d6f9344 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/dto/response/PostDetailResponse.java @@ -0,0 +1,30 @@ +package com.prgrms.board.domain.post.dto.response; + +import java.time.LocalDateTime; + +import com.prgrms.board.domain.post.entity.Post; + +import lombok.Builder; + +@Builder +public record PostDetailResponse( + Long postId, + Long userId, + String username, + String title, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { + public static PostDetailResponse from(Post post) { + return PostDetailResponse.builder() + .postId(post.getId()) + .userId(post.getUser().getId()) + .username(post.getUser().getName()) + .title(post.getTitle()) + .content(post.getContent()) + .createdAt(post.getCreatedAt()) + .updatedAt(post.getUpdatedAt()) + .build(); + } +} diff --git a/src/main/java/com/prgrms/board/domain/post/dto/response/PostResponse.java b/src/main/java/com/prgrms/board/domain/post/dto/response/PostResponse.java new file mode 100644 index 000000000..455090ea3 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/dto/response/PostResponse.java @@ -0,0 +1,20 @@ +package com.prgrms.board.domain.post.dto.response; + +import com.prgrms.board.domain.post.entity.Post; + +import lombok.Builder; + +@Builder +public record PostResponse( + Long postId, + String title, + String content +) { + public static PostResponse from(Post post) { + return PostResponse.builder() + .postId(post.getId()) + .title(post.getTitle()) + .content(post.getContent()) + .build(); + } +} diff --git a/src/main/java/com/prgrms/board/domain/post/entity/Post.java b/src/main/java/com/prgrms/board/domain/post/entity/Post.java new file mode 100644 index 000000000..d0254d6fe --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/entity/Post.java @@ -0,0 +1,58 @@ +package com.prgrms.board.domain.post.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.prgrms.board.domain.user.entity.User; +import com.prgrms.board.global.common.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "post") +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "post_id") + private Long id; + + @JsonIgnore + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "title", length = 30, nullable = false) + private String title; + + @Column(name = "content", nullable = false, columnDefinition = "text") + private String content; + + public static Post create(User user, String title, String content) { + return Post.builder() + .user(user) + .title(title) + .content(content) + .build(); + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/com/prgrms/board/domain/post/exception/PostNotFoundException.java b/src/main/java/com/prgrms/board/domain/post/exception/PostNotFoundException.java new file mode 100644 index 000000000..c7518bdea --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/exception/PostNotFoundException.java @@ -0,0 +1,11 @@ +package com.prgrms.board.domain.post.exception; + +import com.prgrms.board.global.common.ErrorCode; +import com.prgrms.board.global.exception.CustomException; + +public class PostNotFoundException extends CustomException { + + public PostNotFoundException(ErrorCode error) { + super(error); + } +} diff --git a/src/main/java/com/prgrms/board/domain/post/repository/PostRepository.java b/src/main/java/com/prgrms/board/domain/post/repository/PostRepository.java new file mode 100644 index 000000000..4c4f94043 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/repository/PostRepository.java @@ -0,0 +1,8 @@ +package com.prgrms.board.domain.post.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.prgrms.board.domain.post.entity.Post; + +public interface PostRepository extends JpaRepository { +} diff --git a/src/main/java/com/prgrms/board/domain/post/service/PostService.java b/src/main/java/com/prgrms/board/domain/post/service/PostService.java new file mode 100644 index 000000000..cd472e0f7 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/post/service/PostService.java @@ -0,0 +1,59 @@ +package com.prgrms.board.domain.post.service; + +import static com.prgrms.board.global.common.ErrorCode.*; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.prgrms.board.domain.post.dto.request.PostCreateRequest; +import com.prgrms.board.domain.post.dto.request.PostUpdateRequest; +import com.prgrms.board.domain.post.dto.response.PostDetailResponse; +import com.prgrms.board.domain.post.dto.response.PostResponse; +import com.prgrms.board.domain.post.entity.Post; +import com.prgrms.board.domain.post.exception.PostNotFoundException; +import com.prgrms.board.domain.post.repository.PostRepository; +import com.prgrms.board.domain.user.entity.User; +import com.prgrms.board.domain.user.service.UserService; +import com.prgrms.board.global.common.dto.PageResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PostService { + + private final PostRepository postRepository; + private final UserService userService; + + public PageResponse getPosts(Pageable pageable) { + Page posts = postRepository.findAll(pageable); + return PageResponse.from(posts); + } + + public PostDetailResponse getPost(Long postId) { + Post post = findPostOrThrow(postId); + return PostDetailResponse.from(post); + } + + @Transactional + public PostResponse createPost(PostCreateRequest request) { + User user = userService.findUserOrThrow(request.userId()); + Post post = postRepository.save(request.toEntity(user)); + return PostResponse.from(post); + } + + @Transactional + public PostResponse updatePost(Long postId, PostUpdateRequest request) { + Post post = findPostOrThrow(postId); + post.update(request.title(), request.content()); + return PostResponse.from(post); + } + + private Post findPostOrThrow(Long postId) { + return postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(NO_POST)); + } +} diff --git a/src/main/java/com/prgrms/board/domain/user/entity/User.java b/src/main/java/com/prgrms/board/domain/user/entity/User.java new file mode 100644 index 000000000..c6c7916e8 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/user/entity/User.java @@ -0,0 +1,46 @@ +package com.prgrms.board.domain.user.entity; + +import com.prgrms.board.global.common.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "user") +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + @Column(name = "user_id") + private Long id; + + @Column(name = "name", length = 30, nullable = false) + private String name; + + @Column(name = "age", nullable = false) + private int age; + + @Column(name = "hobby", length = 50) + private String hobby; + + public static User create(String name, int age, String hobby) { + return User.builder() + .name(name) + .age(age) + .hobby(hobby) + .build(); + } +} diff --git a/src/main/java/com/prgrms/board/domain/user/exception/UserNotFoundException.java b/src/main/java/com/prgrms/board/domain/user/exception/UserNotFoundException.java new file mode 100644 index 000000000..871d18265 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/user/exception/UserNotFoundException.java @@ -0,0 +1,11 @@ +package com.prgrms.board.domain.user.exception; + +import com.prgrms.board.global.common.ErrorCode; +import com.prgrms.board.global.exception.CustomException; + +public class UserNotFoundException extends CustomException { + + public UserNotFoundException(ErrorCode error) { + super(error); + } +} diff --git a/src/main/java/com/prgrms/board/domain/user/repository/UserRepository.java b/src/main/java/com/prgrms/board/domain/user/repository/UserRepository.java new file mode 100644 index 000000000..c048c831d --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/user/repository/UserRepository.java @@ -0,0 +1,8 @@ +package com.prgrms.board.domain.user.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.prgrms.board.domain.user.entity.User; + +public interface UserRepository extends JpaRepository { +} diff --git a/src/main/java/com/prgrms/board/domain/user/service/UserService.java b/src/main/java/com/prgrms/board/domain/user/service/UserService.java new file mode 100644 index 000000000..fcc7d8d69 --- /dev/null +++ b/src/main/java/com/prgrms/board/domain/user/service/UserService.java @@ -0,0 +1,25 @@ +package com.prgrms.board.domain.user.service; + +import static com.prgrms.board.global.common.ErrorCode.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.prgrms.board.domain.user.entity.User; +import com.prgrms.board.domain.user.exception.UserNotFoundException; +import com.prgrms.board.domain.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserService { + + private final UserRepository userRepository; + + public User findUserOrThrow(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(NO_USER)); + } +} diff --git a/src/main/java/com/prgrms/board/global/common/BaseEntity.java b/src/main/java/com/prgrms/board/global/common/BaseEntity.java new file mode 100644 index 000000000..85c6cc352 --- /dev/null +++ b/src/main/java/com/prgrms/board/global/common/BaseEntity.java @@ -0,0 +1,31 @@ +package com.prgrms.board.global.common; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "timestamp") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false, columnDefinition = "timestamp") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/prgrms/board/global/common/ErrorCode.java b/src/main/java/com/prgrms/board/global/common/ErrorCode.java new file mode 100644 index 000000000..d39fd5393 --- /dev/null +++ b/src/main/java/com/prgrms/board/global/common/ErrorCode.java @@ -0,0 +1,25 @@ +package com.prgrms.board.global.common; + +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum ErrorCode { + + NO_USER(NOT_FOUND, "존재하지 않는 유저입니다."), + NO_POST(NOT_FOUND, "존재하지 않는 게시물입니다."), + ; + + private final HttpStatus status; + private final String message; + + public int getStatusCode() { + return status.value(); + } +} diff --git a/src/main/java/com/prgrms/board/global/common/SuccessMessage.java b/src/main/java/com/prgrms/board/global/common/SuccessMessage.java new file mode 100644 index 000000000..60f3af569 --- /dev/null +++ b/src/main/java/com/prgrms/board/global/common/SuccessMessage.java @@ -0,0 +1,18 @@ +package com.prgrms.board.global.common; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public enum SuccessMessage { + + GET_POSTS_SUCCESS("게시물 전체 조회 성공"), + GET_POST_SUCCESS("게시물 조회 성공"), + CREATE_POST_SUCCESS("게시물 생성 성공"), + UPDATE_POST_SUCCESS("게시물 수정 성공"), + ; + + private final String value; +} diff --git a/src/main/java/com/prgrms/board/global/common/dto/BaseResponse.java b/src/main/java/com/prgrms/board/global/common/dto/BaseResponse.java new file mode 100644 index 000000000..8b6324cce --- /dev/null +++ b/src/main/java/com/prgrms/board/global/common/dto/BaseResponse.java @@ -0,0 +1,43 @@ +package com.prgrms.board.global.common.dto; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.*; +import static org.springframework.http.HttpStatus.*; + +import org.springframework.http.HttpStatus; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.prgrms.board.global.common.ErrorCode; +import com.prgrms.board.global.common.SuccessMessage; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class BaseResponse { + + private final int status; + private final String message; + + @JsonInclude(NON_NULL) + private T data; + + public static BaseResponse ok(SuccessMessage message, T data) { + return new BaseResponse<>(OK.value(), message.getValue(), data); + } + + public static BaseResponse created(SuccessMessage message, T data) { + return new BaseResponse<>(CREATED.value(), message.getValue(), data); + } + + public static BaseResponse error(ErrorCode error) { + return new BaseResponse<>(error.getStatusCode(), error.getMessage()); + } + + public static BaseResponse error(HttpStatus status, String message) { + return new BaseResponse<>(status.value(), message); + } +} diff --git a/src/main/java/com/prgrms/board/global/common/dto/PageResponse.java b/src/main/java/com/prgrms/board/global/common/dto/PageResponse.java new file mode 100644 index 000000000..a4c06427b --- /dev/null +++ b/src/main/java/com/prgrms/board/global/common/dto/PageResponse.java @@ -0,0 +1,26 @@ +package com.prgrms.board.global.common.dto; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import lombok.Builder; + +@Builder +public record PageResponse( + long totalCount, + int totalPage, + int page, + int size, + List items +) { + public static PageResponse from(Page page) { + return PageResponse.builder() + .totalCount(page.getTotalElements()) + .totalPage(page.getTotalPages()) + .page(page.getNumber()) + .size(page.getSize()) + .items(page.getContent()) + .build(); + } +} diff --git a/src/main/java/com/prgrms/board/global/configuration/JpaConfig.java b/src/main/java/com/prgrms/board/global/configuration/JpaConfig.java new file mode 100644 index 000000000..6d6d77515 --- /dev/null +++ b/src/main/java/com/prgrms/board/global/configuration/JpaConfig.java @@ -0,0 +1,9 @@ +package com.prgrms.board.global.configuration; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/src/main/java/com/prgrms/board/global/exception/CustomException.java b/src/main/java/com/prgrms/board/global/exception/CustomException.java new file mode 100644 index 000000000..9ddc81f03 --- /dev/null +++ b/src/main/java/com/prgrms/board/global/exception/CustomException.java @@ -0,0 +1,16 @@ +package com.prgrms.board.global.exception; + +import com.prgrms.board.global.common.ErrorCode; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode error; + + public CustomException(ErrorCode error) { + super(error.getMessage()); + this.error = error; + } +} diff --git a/src/main/java/com/prgrms/board/global/exception/ExceptionControllerAdvice.java b/src/main/java/com/prgrms/board/global/exception/ExceptionControllerAdvice.java new file mode 100644 index 000000000..2bae0bf3d --- /dev/null +++ b/src/main/java/com/prgrms/board/global/exception/ExceptionControllerAdvice.java @@ -0,0 +1,43 @@ +package com.prgrms.board.global.exception; + +import static org.springframework.http.HttpStatus.*; + +import java.util.Optional; + +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.prgrms.board.domain.post.exception.PostNotFoundException; +import com.prgrms.board.domain.user.exception.UserNotFoundException; +import com.prgrms.board.global.common.dto.BaseResponse; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestControllerAdvice +public class ExceptionControllerAdvice { + + @ResponseStatus(NOT_FOUND) + @ExceptionHandler({ + UserNotFoundException.class, + PostNotFoundException.class, + }) + public BaseResponse handleNotFound(CustomException exception) { + log.error("[NotFound] => ", exception); + return BaseResponse.error(exception.getError()); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public BaseResponse handleBadRequestException(MethodArgumentNotValidException exception) { + log.error("[BadRequest] => ", exception); + + String errorMessage = Optional.ofNullable(exception.getBindingResult().getFieldError()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .orElse("입력 값이 올바르지 않습니다."); + return BaseResponse.error(BAD_REQUEST, errorMessage); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 000000000..f971a9efa --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,17 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3306/board?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: root + password: 1234 + + jpa: + open-in-view: true + show-sql: true + properties: + hibernate: + format_sql: true + hbm2ddl: + auto: create + database: mysql + generate-ddl: true diff --git a/src/test/java/com/prgrms/board/domain/post/controller/ApiPostControllerTest.java b/src/test/java/com/prgrms/board/domain/post/controller/ApiPostControllerTest.java new file mode 100644 index 000000000..ca9dff3ef --- /dev/null +++ b/src/test/java/com/prgrms/board/domain/post/controller/ApiPostControllerTest.java @@ -0,0 +1,164 @@ +package com.prgrms.board.domain.post.controller; + +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +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.restdocs.AutoConfigureRestDocs; +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.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.prgrms.board.domain.post.dto.request.PostCreateRequest; +import com.prgrms.board.domain.post.dto.request.PostUpdateRequest; +import com.prgrms.board.domain.post.entity.Post; +import com.prgrms.board.domain.post.repository.PostRepository; +import com.prgrms.board.domain.user.entity.User; +import com.prgrms.board.domain.user.repository.UserRepository; +import com.prgrms.board.support.PostFixture; +import com.prgrms.board.support.UserFixture; + +@Transactional +@SpringBootTest +@AutoConfigureRestDocs +@AutoConfigureMockMvc +class ApiPostControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private PostRepository postRepository; + + @Autowired + private UserRepository userRepository; + + private User user; + + @BeforeEach + void init() { + user = UserFixture.user().build(); + userRepository.save(user); + } + + @Test + @DisplayName("게시물 생성에 성공한다.") + void create_post_success() throws Exception { + // given + PostCreateRequest request = new PostCreateRequest(user.getId(), "제목", "내용"); + + // when & then + mockMvc.perform(post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("posts/createPost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint())) + ) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.message").value("게시물 생성 성공")) + .andExpect(jsonPath("$.data.postId").exists()) + .andExpect(jsonPath("$.data.title").value(request.title())) + .andExpect(jsonPath("$.data.content").value(request.content())); + } + + @Test + @DisplayName("게시물 전체 조회에 성공한다.") + void get_posts_success() throws Exception { + // given + Post post1 = PostFixture.post().user(user).title("제목1").content("내용1").build(); + Post post2 = PostFixture.post().user(user).title("제목2").content("내용2").build(); + Post post3 = PostFixture.post().user(user).title("제목3").content("내용3").build(); + List posts = List.of(post1, post2, post3); + postRepository.saveAll(posts); + + int page = 0; + int size = 2; + int totalCount = posts.size(); + int totalPage = (int)Math.ceil((double)posts.size() / size); + + // when & then + mockMvc.perform(get("/posts") + .param("page", String.valueOf(page)) + .param("size", String.valueOf(size)) + ) + .andDo(print()) + .andDo(document("posts/getPosts", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint())) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("게시물 전체 조회 성공")) + .andExpect(jsonPath("$.data.totalCount").value(totalCount)) + .andExpect(jsonPath("$.data.totalPage").value(totalPage)) + .andExpect(jsonPath("$.data.page").value(page)) + .andExpect(jsonPath("$.data.size").value(size)) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items.length()").value(size)); + } + + @Test + @DisplayName("게시물 조회에 성공한다.") + void get_post_success() throws Exception { + // given + Post post = PostFixture.post().user(user).build(); + postRepository.save(post); + + // when & then + mockMvc.perform(get("/posts/" + post.getId())) + .andDo(print()) + .andDo(document("posts/getPost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint())) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("게시물 조회 성공")) + .andExpect(jsonPath("$.data.postId").value(post.getId())) + .andExpect(jsonPath("$.data.title").value(post.getTitle())) + .andExpect(jsonPath("$.data.content").value(post.getContent())) + .andExpect(jsonPath("$.data.userId").value(user.getId())) + .andExpect(jsonPath("$.data.username").value(user.getName())) + .andExpect(jsonPath("$.data.createdAt").exists()) + .andExpect(jsonPath("$.data.updatedAt").exists()); + } + + @Test + @DisplayName("게시물 수정에 성공한다.") + void update_post_success() throws Exception { + // given + Post post = PostFixture.post().user(user).build(); + postRepository.save(post); + PostUpdateRequest request = new PostUpdateRequest("수정한 제목", "수정한 내용"); + + // when & then + mockMvc.perform(patch("/posts/" + post.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ) + .andDo(print()) + .andDo(document("posts/updatePost", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint())) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("게시물 수정 성공")) + .andExpect(jsonPath("$.data.postId").value(post.getId())) + .andExpect(jsonPath("$.data.title").value(request.title())) + .andExpect(jsonPath("$.data.content").value(request.content())); + } +} diff --git a/src/test/java/com/prgrms/board/domain/post/service/PostServiceTest.java b/src/test/java/com/prgrms/board/domain/post/service/PostServiceTest.java new file mode 100644 index 000000000..d7d00bdcd --- /dev/null +++ b/src/test/java/com/prgrms/board/domain/post/service/PostServiceTest.java @@ -0,0 +1,132 @@ +package com.prgrms.board.domain.post.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import com.prgrms.board.domain.post.dto.request.PostCreateRequest; +import com.prgrms.board.domain.post.dto.request.PostUpdateRequest; +import com.prgrms.board.domain.post.dto.response.PostDetailResponse; +import com.prgrms.board.domain.post.dto.response.PostResponse; +import com.prgrms.board.domain.post.entity.Post; +import com.prgrms.board.domain.post.exception.PostNotFoundException; +import com.prgrms.board.domain.post.repository.PostRepository; +import com.prgrms.board.domain.user.entity.User; +import com.prgrms.board.domain.user.service.UserService; +import com.prgrms.board.global.common.dto.PageResponse; +import com.prgrms.board.support.PostFixture; +import com.prgrms.board.support.UserFixture; + +@ExtendWith(MockitoExtension.class) +class PostServiceTest { + + @InjectMocks + private PostService postService; + + @Mock + private PostRepository postRepository; + + @Mock + private UserService userService; + + @Test + @DisplayName("게시물 생성에 성공한다.") + void create_post_success() { + // given + User user = UserFixture.user().id(1L).build(); + Post post = PostFixture.post().build(); + PostCreateRequest request = new PostCreateRequest(user.getId(), "제목", "내용"); + + given(userService.findUserOrThrow(user.getId())).willReturn(user); + given(postRepository.save(any(Post.class))).willReturn(post); + + // when + PostResponse result = postService.createPost(request); + + // then + assertThat(result.title()).isEqualTo("제목"); + assertThat(result.content()).isEqualTo("내용"); + } + + @Test + @DisplayName("전체 게시물 조회에 성공한다.") + void get_posts_success() { + // given + Post post1 = PostFixture.post().title("제목1").content("내용1").build(); + Post post2 = PostFixture.post().title("제목2").content("내용2").build(); + Post post3 = PostFixture.post().title("제목3").content("내용3").build(); + List posts = List.of(post1, post2, post3); + + Pageable pageable = PageRequest.of(0, 2); + Page page = new PageImpl<>(posts.subList(0, 2), pageable, posts.size()); + + given(postRepository.findAll(any(Pageable.class))).willReturn(page); + + // when + PageResponse result = postService.getPosts(pageable); + + // then + assertThat(result.totalCount()).isEqualTo(3); + assertThat(result.totalPage()).isEqualTo(2); + assertThat(result.items()).hasSize(2); + } + + @Test + @DisplayName("특정 게시물 조회에 성공한다.") + void get_post_success() { + // given + Post post = PostFixture.post().id(1L).build(); + given(postRepository.findById(post.getId())).willReturn(Optional.of(post)); + + // when + PostDetailResponse postDetailResponse = postService.getPost(post.getId()); + + // then + assertThat(postDetailResponse.title()).isEqualTo(post.getTitle()); + assertThat(postDetailResponse.content()).isEqualTo(post.getContent()); + } + + @Test + @DisplayName("게시물 수정에 성공한다.") + void update_post_success() { + // given + Post post = PostFixture.post().id(1L).build(); + PostUpdateRequest request = new PostUpdateRequest("수정한 제목", "수정한 내용"); + + given(postRepository.findById(post.getId())).willReturn(Optional.of(post)); + + // when + PostResponse result = postService.updatePost(post.getId(), request); + + // then + assertThat(result.title()).isEqualTo("수정한 제목"); + assertThat(result.content()).isEqualTo("수정한 내용"); + } + + @Test + @DisplayName("존재하는 게시물이 없는 경우 예외가 발생한다.") + void get_post_not_found() { + // given + Long postId = 1L; + given(postRepository.findById(postId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> postService.getPost(postId)) + .isInstanceOf(PostNotFoundException.class) + .hasMessage("존재하지 않는 게시물입니다."); + } +} diff --git a/src/test/java/com/prgrms/board/support/PostFixture.java b/src/test/java/com/prgrms/board/support/PostFixture.java new file mode 100644 index 000000000..fd818c8c0 --- /dev/null +++ b/src/test/java/com/prgrms/board/support/PostFixture.java @@ -0,0 +1,48 @@ +package com.prgrms.board.support; + +import com.prgrms.board.domain.post.entity.Post; +import com.prgrms.board.domain.user.entity.User; + +public class PostFixture { + + private Long id; + private String title = "제목"; + private String content = "내용"; + private User user = UserFixture.user().build(); + + private PostFixture() { + } + + public static PostFixture post() { + return new PostFixture(); + } + + public PostFixture id(Long id) { + this.id = id; + return this; + } + + public PostFixture title(String title) { + this.title = title; + return this; + } + + public PostFixture content(String content) { + this.content = content; + return this; + } + + public PostFixture user(User user) { + this.user = user; + return this; + } + + public Post build() { + return Post.builder() + .id(id) + .user(user) + .title(title) + .content(content) + .build(); + } +} diff --git a/src/test/java/com/prgrms/board/support/UserFixture.java b/src/test/java/com/prgrms/board/support/UserFixture.java new file mode 100644 index 000000000..f1ca4054f --- /dev/null +++ b/src/test/java/com/prgrms/board/support/UserFixture.java @@ -0,0 +1,30 @@ +package com.prgrms.board.support; + +import com.prgrms.board.domain.user.entity.User; + +public class UserFixture { + + private Long id; + private String name = "이름"; + private int age = 10; + + private UserFixture() { + } + + public static UserFixture user() { + return new UserFixture(); + } + + public UserFixture id(Long id) { + this.id = id; + return this; + } + + public User build() { + return User.builder() + .id(id) + .name(name) + .age(age) + .build(); + } +}