diff --git a/build.gradle b/build.gradle index a908a53bd..f85de9560 100644 --- a/build.gradle +++ b/build.gradle @@ -71,6 +71,9 @@ dependencies { //Google Guava implementation 'com.google.guava:guava:33.2.0-jre' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.8' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java b/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java index 9039a4fe2..63cf156a8 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java @@ -1,15 +1,26 @@ package konkuk.thip.book.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.validation.constraints.Pattern; import konkuk.thip.book.adapter.in.web.request.PostBookIsSavedRequest; import konkuk.thip.book.adapter.in.web.response.PostBookIsSavedResponse; import konkuk.thip.book.application.port.in.BookSavedUseCase; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; +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.RestController; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.CHANGE_BOOK_SAVED_STATE; + +@Tag(name = "Book Command API", description = "책 상태변경 관련 API") @Validated @RestController @RequiredArgsConstructor @@ -17,12 +28,18 @@ public class BookCommandController { private final BookSavedUseCase bookSavedUseCase; - //책 저장 상태 변경 + @Operation( + summary = "책 저장 상태 변경", + description = "사용자가 책의 저장 상태를 변경합니다. (true -> 저장, false -> 저장 취소)" + ) + @ExceptionDescription(CHANGE_BOOK_SAVED_STATE) @PostMapping("/books/{isbn}/saved") - public BaseResponse changeSavedBook(@PathVariable("isbn") - @Pattern(regexp = "\\d{13}") final String isbn, - @RequestBody final PostBookIsSavedRequest postBookIsSavedRequest, - @UserId final Long userId) { + public BaseResponse changeSavedBook( + @Parameter(description = "책의 ISBN 번호 (13자리 숫자)", example = "9781234567890") + @PathVariable("isbn") @Pattern(regexp = "\\d{13}") final String isbn, + @RequestBody @Valid final PostBookIsSavedRequest postBookIsSavedRequest, + @Parameter(hidden = true) @UserId final Long userId + ) { return BaseResponse.ok(PostBookIsSavedResponse.of(bookSavedUseCase.changeSavedBook(isbn,postBookIsSavedRequest.type(),userId))); } diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java index b1e7de2a9..bf3a80380 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java @@ -1,17 +1,27 @@ package konkuk.thip.book.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Pattern; import konkuk.thip.book.adapter.in.web.response.GetBookDetailSearchResponse; import konkuk.thip.book.adapter.in.web.response.GetBookMostSearchResponse; import konkuk.thip.book.adapter.in.web.response.GetBookSearchListResponse; -import konkuk.thip.book.application.port.in.BookSearchUseCase; import konkuk.thip.book.application.port.in.BookMostSearchUseCase; +import konkuk.thip.book.application.port.in.BookSearchUseCase; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.*; + +@Tag(name = "Book Query API", description = "책 조회 관련 API") @Validated @RestController @RequiredArgsConstructor @@ -20,30 +30,43 @@ public class BookQueryController { private final BookSearchUseCase bookSearchUseCase; private final BookMostSearchUseCase bookMostSearchUseCase; - - //책 검색결과 조회 + @Operation( + summary = "책 검색결과 조회", + description = "사용자가 입력한 키워드로 책을 검색합니다." + ) + @ExceptionDescription(BOOK_SEARCH) @GetMapping("/books") - public BaseResponse getBookSearchList(@RequestParam final String keyword, - @RequestParam final int page, - @UserId final Long userId) { + public BaseResponse getBookSearchList( + @Parameter(description = "검색 키워드", example = "해리포터") @RequestParam final String keyword, + @Parameter(description = "페이지 번호 (1부터 시작)", example = "1") @RequestParam final int page, + @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(GetBookSearchListResponse.of(bookSearchUseCase.searchBooks(keyword, page,userId), page)); } //책 상세검색 결과 조회 + @Operation( + summary = "책 상세검색 결과 조회", + description = "ISBN을 통해 책의 상세 정보를 조회합니다." + ) + @ExceptionDescription(BOOK_DETAIL_SEARCH) @GetMapping("/books/{isbn}") - public BaseResponse getBookDetailSearch(@PathVariable("isbn") - @Pattern(regexp = "\\d{13}") final String isbn, - @UserId final Long userId) { - - - + public BaseResponse getBookDetailSearch( + @Parameter(description = "책의 ISBN 번호 (13자리 숫자)", example = "9781234567890") + @PathVariable("isbn") @Pattern(regexp = "\\d{13}") final String isbn, + @Parameter(hidden = true) @UserId final Long userId + ) { return BaseResponse.ok(GetBookDetailSearchResponse.of(bookSearchUseCase.searchDetailBooks(isbn,userId))); } //가장 많이 검색된 책 조회 + @Operation( + summary = "가장 많이 검색된 책 조회", + description = "사용자가 가장 많이 검색한 책들을 조회합니다." + ) + @ExceptionDescription(POPULAR_BOOK_SEARCH) @GetMapping("/books/most-searched") - public BaseResponse getMostSearchedBooks(@UserId final Long userId) { - + public BaseResponse getMostSearchedBooks( + @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(GetBookMostSearchResponse.of(bookMostSearchUseCase.getMostSearchedBooks(userId))); } diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java b/src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java index ba7b7b9bf..f08bd9796 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java @@ -1,10 +1,12 @@ package konkuk.thip.book.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; - +@Schema(description = "책 저장 상태 변경 요청 DTO") public record PostBookIsSavedRequest( + @Schema(description = "저장 여부 type (true -> 저장, false -> 저장 취소)", example = "true") @NotNull(message = "type은 필수입니다.") - boolean type + Boolean type ) { } diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java index 8898571f1..492856503 100644 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/CommentCommandController.java @@ -1,5 +1,8 @@ package konkuk.thip.comment.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import konkuk.thip.comment.adapter.in.web.request.CommentCreateRequest; import konkuk.thip.comment.adapter.in.web.request.CommentIsLikeRequest; @@ -9,9 +12,17 @@ import konkuk.thip.comment.application.port.in.CommentLikeUseCase; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +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.RestController; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.CHANGE_COMMENT_LIKE_STATE; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.COMMENT_CREATE; + +@Tag(name = "Comment Command API", description = "댓글 상태변경 관련 API") @RestController @RequiredArgsConstructor public class CommentCommandController { @@ -24,18 +35,31 @@ public class CommentCommandController { * parentId:{Long},isReplyRequest:true 답글 * parentId:null,isReplyRequest:false 댓글 */ + @Operation( + summary = "댓글 작성", + description = "사용자가 댓글을 작성합니다.\n" + + "답글 작성 시 parentId를 지정하고 isReplyRequest를 true로 설정합니다. " + + "댓글 작성 시 parentId는 null로 설정하고 isReplyRequest를 false로 설정합니다." + ) + @ExceptionDescription(COMMENT_CREATE) @PostMapping("/comments/{postId}") - public BaseResponse createComment(@RequestBody @Valid final CommentCreateRequest request, - @PathVariable("postId") final Long postId, - @UserId final Long userId) { + public BaseResponse createComment( + @RequestBody @Valid final CommentCreateRequest request, + @Parameter(description = "댓글을 작성하려는 게시물 ID", example = "1") @PathVariable("postId") final Long postId, + @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(CommentIdResponse.of(commentCreateUseCase.createComment(request.toCommand(userId,postId)))); } - //댓글 좋아요 상태 변경: true -> 좋아요, false -> 좋아요 취소 + @Operation( + summary = "댓글 좋아요 상태 변경", + description = "사용자가 댓글의 좋아요 상태를 변경합니다. (true -> 좋아요, false -> 좋아요 취소)" + ) + @ExceptionDescription(CHANGE_COMMENT_LIKE_STATE) @PostMapping("/comments/{commentId}/likes") - public BaseResponse likeComment(@RequestBody @Valid final CommentIsLikeRequest request, - @PathVariable("commentId") final Long commentId, - @UserId final Long userId) { + public BaseResponse likeComment( + @RequestBody @Valid final CommentIsLikeRequest request, + @Parameter(description = "좋아요 상태를 변경하려는 댓글 ID", example = "1") @PathVariable("commentId") final Long commentId, + @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(CommentIsLikeResponse.of(commentLikeUseCase.changeLikeStatusComment(request.toCommand(userId, commentId)))); } diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java b/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java index dbc5abc6f..976864530 100644 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentCreateRequest.java @@ -1,19 +1,25 @@ package konkuk.thip.comment.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import konkuk.thip.comment.application.port.in.dto.CommentCreateCommand; +@Schema(description = "댓글 작성 요청 DTO") public record CommentCreateRequest( + @Schema(description = "댓글 내용", example = "이 게시물 정말 좋아요!") @NotBlank(message = "댓글 내용은 필수입니다.") String content, + @Schema(description = "답글 여부", example = "true") @NotNull(message = "답글 여부는 필수입니다.") Boolean isReplyRequest, + @Schema(description = "답글의 부모 댓글 ID (답글이 아닐 경우 null)", example = "1") Long parentId, + @Schema(description = "게시물 타입 (RECORD, VOTE, FEED)", example = "RECORD") @NotBlank(message = "게시물 타입은 필수입니다.") String postType diff --git a/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentIsLikeRequest.java b/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentIsLikeRequest.java index 72da84898..a14d9b4c7 100644 --- a/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentIsLikeRequest.java +++ b/src/main/java/konkuk/thip/comment/adapter/in/web/request/CommentIsLikeRequest.java @@ -1,9 +1,12 @@ package konkuk.thip.comment.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import konkuk.thip.comment.application.port.in.dto.CommentIsLikeCommand; +@Schema(description = "댓글 좋아요 상태 변경 요청 DTO") public record CommentIsLikeRequest( + @Schema(description = "좋아요 여부 type (true -> 좋아요, false -> 좋아요 취소)", example = "true") @NotNull(message = "좋아요 여부는 필수입니다.") Boolean type ) { diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 760675ece..d6b449b83 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -50,11 +50,11 @@ public enum ErrorCode implements ResponseCode { /** * 80000 : book error */ - BOOK_KEYWORD_ENCODING_FAILED(HttpStatus.BAD_REQUEST, 80000, "검색어 인코딩에 실패했습니다."), - BOOK_NAVER_API_REQUEST_ERROR(HttpStatus.BAD_REQUEST, 80001,"네이버 API 요청에 실패하였습니다."), + BOOK_KEYWORD_ENCODING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 80000, "검색어 인코딩에 실패했습니다."), + BOOK_NAVER_API_REQUEST_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80001,"네이버 API 요청에 실패하였습니다."), BOOK_NAVER_API_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80002,"네이버 API 응답 파싱에 실패하였습니다."), - BOOK_NAVER_API_URL_ERROR(HttpStatus.BAD_REQUEST, 80003,"네이버 API URL이 잘못되었습니다."), - BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED(HttpStatus.BAD_REQUEST, 80004,"네이버 API 요청 중, HTTP 연결에 실패하였습니다."), + BOOK_NAVER_API_URL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80003,"네이버 API URL이 잘못되었습니다."), + BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 80004,"네이버 API 요청 중, HTTP 연결에 실패하였습니다."), BOOK_NAVER_API_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80005,"네이버 API 응답에 실패하였습니다."), BOOK_SEARCH_PAGE_OUT_OF_RANGE(HttpStatus.BAD_REQUEST, 80006,"검색어 페이지가 범위를 벗어났습니다."), BOOK_KEYWORD_REQUIRED(HttpStatus.BAD_REQUEST, 80007, "검색어는 필수 입력값입니다."), diff --git a/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java b/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java index abcb45aa5..3cf0920da 100644 --- a/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java @@ -76,4 +76,16 @@ private String extractToken(HttpServletRequest request) { return null; } + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + + // 화이트리스트 경로에 대해서는 JWT 필터 제외 + return path.startsWith("/swagger-ui") + || path.startsWith("/v3/api-docs") + || path.startsWith("/api-docs") + || path.startsWith("/oauth2/authorization") + || path.startsWith("/login/oauth2/code"); + } + } diff --git a/src/main/java/konkuk/thip/common/swagger/ExampleHolder.java b/src/main/java/konkuk/thip/common/swagger/ExampleHolder.java new file mode 100644 index 000000000..5ed307bee --- /dev/null +++ b/src/main/java/konkuk/thip/common/swagger/ExampleHolder.java @@ -0,0 +1,14 @@ +package konkuk.thip.common.swagger; + +import io.swagger.v3.oas.models.examples.Example; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExampleHolder { + + private Example holder; + private String name; + private int code; +} diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java new file mode 100644 index 000000000..2dc99468b --- /dev/null +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -0,0 +1,220 @@ +package konkuk.thip.common.swagger; + +import konkuk.thip.common.exception.code.ErrorCode; +import lombok.Getter; + +import java.util.LinkedHashSet; +import java.util.Set; + +import static konkuk.thip.common.exception.code.ErrorCode.*; + +@Getter +public enum SwaggerResponseDescription { +// + //Auth + LOGIN(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND + ))), + LOGOUT(new LinkedHashSet<>(Set.of( + + ))), + + //User + USER_SIGNUP(new LinkedHashSet<>(Set.of( + ALIAS_NAME_NOT_MATCH + ))), + + // Follow + CHANGE_FOLLOW_STATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + USER_ALREADY_FOLLOWED, + USER_ALREADY_UNFOLLOWED, + USER_CANNOT_FOLLOW_SELF, + FOLLOW_COUNT_IS_ZERO + ))), + GET_USER_FOLLOW(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND + ))), + + // Room + ROOM_CREATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + BOOK_ALADIN_API_PARSING_ERROR, + BOOK_ALADIN_API_ISBN_NOT_FOUND + + ))), + ROOM_JOIN_CANCEL(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + ROOM_NOT_FOUND, + ROOM_RECRUITMENT_PERIOD_EXPIRED, + USER_CANNOT_JOIN_OR_CANCEL, + ROOM_MEMBER_COUNT_EXCEEDED, + USER_ALREADY_PARTICIPATE, + ROOM_MEMBER_COUNT_UNDERFLOW, + USER_NOT_PARTICIPATED_CANNOT_CANCEL, + HOST_CANNOT_CANCEL + ))), + ROOM_RECRUIT_CLOSE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + ROOM_NOT_FOUND, + ROOM_RECRUITMENT_PERIOD_EXPIRED, + ROOM_RECRUIT_CANNOT_CLOSED + ))), + + ROOM_SEARCH(new LinkedHashSet<>(Set.of( + INVALID_ROOM_SEARCH_SORT + ))), + ROOM_PASSWORD_CHECK(new LinkedHashSet<>(Set.of( + ROOM_NOT_FOUND, + ROOM_PASSWORD_MISMATCH, + ROOM_RECRUITMENT_PERIOD_EXPIRED, + ROOM_PASSWORD_NOT_REQUIRED + ))), + ROOM_RECRUITING_DETAIL_VIEW(new LinkedHashSet<>(Set.of( + ROOM_NOT_FOUND + ))), + ROOM_GET_HOME_JOINED_LIST(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND + ))), + ROOM_GET_MEMBER_LIST(new LinkedHashSet<>(Set.of( + ROOM_NOT_FOUND + ))), + ROOM_PLAYING_DETAIL(new LinkedHashSet<>(Set.of( + BOOK_NOT_FOUND, + ROOM_NOT_FOUND + ))), + + + // Record + RECORD_CREATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + ROOM_NOT_FOUND, + BOOK_NOT_FOUND, + ROOM_IS_EXPIRED, + RECORD_CANNOT_BE_OVERVIEW, + INVALID_RECORD_PAGE_RANGE + ))), + RECORD_SEARCH(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + ROOM_NOT_FOUND, + BOOK_NOT_FOUND, + USER_NOT_BELONG_TO_ROOM + ))), + + // Vote + VOTE_CREATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + ROOM_NOT_FOUND, + BOOK_NOT_FOUND, + ROOM_IS_EXPIRED, + VOTE_CANNOT_BE_OVERVIEW, + INVALID_VOTE_PAGE_RANGE + ))), + + // FEED + FEED_CREATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + BOOK_NOT_FOUND, + TAG_NOT_FOUND, + TAG_NAME_NOT_MATCH, + INVALID_FEED_COMMAND, + BOOK_NAVER_API_PARSING_ERROR, + BOOK_NAVER_API_ISBN_NOT_FOUND, + EMPTY_FILE_EXCEPTION, + EXCEPTION_ON_IMAGE_UPLOAD, + INVALID_FILE_EXTENSION + ))), + FEED_UPDATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + FEED_NOT_FOUND, + BOOK_NOT_FOUND, + TAG_NOT_FOUND, + TAG_NAME_NOT_MATCH, + INVALID_FEED_COMMAND, + FEED_ACCESS_FORBIDDEN + ))), + CHANGE_FEED_SAVED_STATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + FEED_NOT_FOUND, + FEED_ALREADY_SAVED, + FEED_NOT_SAVED_CANNOT_DELETE + ))), + + // Comment + COMMENT_CREATE(new LinkedHashSet<>(Set.of( + POST_TYPE_NOT_MATCH, + USER_NOT_FOUND, + FEED_NOT_FOUND, + RECORD_NOT_FOUND, + VOTE_NOT_FOUND, + INVALID_COMMENT_CREATE, + FEED_ACCESS_FORBIDDEN, + ROOM_ACCESS_FORBIDDEN + + ))), + CHANGE_COMMENT_LIKE_STATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + COMMENT_NOT_FOUND, + FEED_NOT_FOUND, + RECORD_NOT_FOUND, + VOTE_NOT_FOUND, + COMMENT_ALREADY_LIKED, + COMMENT_NOT_LIKED_CANNOT_CANCEL, + COMMENT_LIKE_COUNT_UNDERFLOW + ))), + + // Book + CHANGE_BOOK_SAVED_STATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + BOOK_NOT_FOUND, + BOOK_ALREADY_SAVED, + BOOK_NOT_SAVED_CANNOT_DELETE +// DUPLICATED_BOOKS_IN_COLLECTION, +// BOOK_NOT_SAVED_DB_CANNOT_DELETE + ))), + BOOK_SEARCH(new LinkedHashSet<>(Set.of( + BOOK_SEARCH_PAGE_OUT_OF_RANGE, + BOOK_KEYWORD_REQUIRED, + BOOK_PAGE_NUMBER_INVALID, + BOOK_NAVER_API_PARSING_ERROR, + BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED, + BOOK_NAVER_API_RESPONSE_ERROR + ))), + BOOK_DETAIL_SEARCH(new LinkedHashSet<>(Set.of( + BOOK_NOT_FOUND, + BOOK_NAVER_API_PARSING_ERROR, + BOOK_NAVER_API_ISBN_NOT_FOUND, + BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED + ))), + POPULAR_BOOK_SEARCH(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + JSON_PROCESSING_ERROR + ))), + + ; + private final Set errorCodeList; + SwaggerResponseDescription(Set errorCodeList) { + // 공통 에러 + errorCodeList.addAll(new LinkedHashSet<>(Set.of( + API_NOT_FOUND, + API_METHOD_NOT_ALLOWED, + API_SERVER_ERROR, + + API_MISSING_PARAM, + API_INVALID_PARAM, + API_INVALID_TYPE + +// AUTH_INVALID_TOKEN, +// AUTH_EXPIRED_TOKEN, +// AUTH_UNAUTHORIZED, +// AUTH_TOKEN_NOT_FOUND +// AUTH_LOGIN_FAILED, +// AUTH_UNSUPPORTED_SOCIAL_LOGIN, + +// JSON_PROCESSING_ERROR + ))); + + + this.errorCodeList = errorCodeList; + } +} diff --git a/src/main/java/konkuk/thip/common/swagger/annotation/ExceptionDescription.java b/src/main/java/konkuk/thip/common/swagger/annotation/ExceptionDescription.java new file mode 100644 index 000000000..f12c1a981 --- /dev/null +++ b/src/main/java/konkuk/thip/common/swagger/annotation/ExceptionDescription.java @@ -0,0 +1,15 @@ +package konkuk.thip.common.swagger.annotation; + +import konkuk.thip.common.swagger.SwaggerResponseDescription; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExceptionDescription { + + SwaggerResponseDescription value(); +} diff --git a/src/main/java/konkuk/thip/config/OpenApiConfig.java b/src/main/java/konkuk/thip/config/OpenApiConfig.java new file mode 100644 index 000000000..23cabe1eb --- /dev/null +++ b/src/main/java/konkuk/thip/config/OpenApiConfig.java @@ -0,0 +1,139 @@ +package konkuk.thip.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import konkuk.thip.common.dto.ErrorResponse; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.swagger.ExampleHolder; +import konkuk.thip.common.swagger.SwaggerResponseDescription; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.web.method.HandlerMethod; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static java.util.stream.Collectors.groupingBy; + +@OpenAPIDefinition( + info = @Info( + title = "Thip 백엔드 API 명세서", + description = "Springdoc을 이용한 Thip Swagger API 문서입니다.", + version = "1.0.0" + ) +) +@Configuration +public class OpenApiConfig { + private final String securitySchemaName = "JWT"; + + @Value("${server.https-url}") private String httpsUrl; + + @Value("${server.http-url}") private String httpUrl; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .servers(List.of( + new Server().url(httpsUrl).description("HTTPS 배포 서버"), + new Server().url(httpUrl).description("HTTP IP"), + new Server().url("http://localhost:8080").description("로컬 개발 서버") + )) + .components(setComponents()) + .addSecurityItem(setSecurityItems()); + } + private Components setComponents() { + return new Components() + .addSecuritySchemes(securitySchemaName, bearerAuth()); + } + + private SecurityScheme bearerAuth() { + return new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("Bearer") + .bearerFormat(securitySchemaName) + .in(SecurityScheme.In.HEADER) + .name(HttpHeaders.AUTHORIZATION); + } + + private SecurityRequirement setSecurityItems() { + return new SecurityRequirement() + .addList(securitySchemaName); + } + + @Bean + public OperationCustomizer customize() { + return (Operation operation, HandlerMethod handlerMethod) -> { + + ExceptionDescription exceptionDescription = handlerMethod.getMethodAnnotation( + ExceptionDescription.class); + + // ExceptionDescription 어노테이션 단 메소드 적용 + if (exceptionDescription != null) { + generateErrorCodeResponseExample(operation, exceptionDescription.value()); + } + + return operation; + }; + } + private void generateErrorCodeResponseExample( + Operation operation, SwaggerResponseDescription type) { + + ApiResponses responses = operation.getResponses(); + + Set errorCodeList = type.getErrorCodeList(); + + Map> statusWithExampleHolders = + errorCodeList.stream() + .map( + errorCode -> ExampleHolder.builder() + .holder(getSwaggerExample(errorCode)) + .code(errorCode.getHttpStatus().value()) + .name(errorCode.toString()) + .build() + ).collect(groupingBy(ExampleHolder::getCode)); + addExamplesToResponses(responses, statusWithExampleHolders); + } + + private Example getSwaggerExample(ErrorCode errorCode) { + ErrorResponse errorResponse = ErrorResponse.of(errorCode); + Example example = new Example(); + example.description(errorCode.getMessage()); + example.setValue(errorResponse); + return example; + } + + private void addExamplesToResponses( + ApiResponses responses, Map> statusWithExampleHolders) { + statusWithExampleHolders.forEach( + (status, v) -> { + Content content = new Content(); + MediaType mediaType = new MediaType(); + ApiResponse apiResponse = new ApiResponse(); + v.forEach( + exampleHolder -> { + mediaType.addExamples( + exampleHolder.getName(), exampleHolder.getHolder()); + }); + content.addMediaType("application/json", mediaType); + apiResponse.setDescription(""); + apiResponse.setContent(content); + responses.addApiResponse(status.toString(), apiResponse); + }); + } +} diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java index c43488005..0513d0eae 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedCommandController.java @@ -1,8 +1,12 @@ package konkuk.thip.feed.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.feed.adapter.in.web.request.FeedCreateRequest; import konkuk.thip.feed.adapter.in.web.request.FeedIsSavedRequest; import konkuk.thip.feed.adapter.in.web.request.FeedUpdateRequest; @@ -12,13 +16,14 @@ import konkuk.thip.feed.application.port.in.FeedSavedUseCase; import konkuk.thip.feed.application.port.in.FeedUpdateUseCase; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import java.util.List; -@Slf4j +import static konkuk.thip.common.swagger.SwaggerResponseDescription.*; + +@Tag(name = "Feed Command API", description = "피드 상태변경 관련 API") @RestController @RequiredArgsConstructor public class FeedCommandController { @@ -27,29 +32,44 @@ public class FeedCommandController { private final FeedUpdateUseCase feedUpdateUseCase; private final FeedSavedUseCase feedSavedUseCase; - //피드 작성 + @Operation( + summary = "피드 작성", + description = "사용자가 피드를 작성합니다." + ) + @ExceptionDescription(FEED_CREATE) @PostMapping("/feeds") - public BaseResponse createFeed(@RequestPart("request") @Valid final FeedCreateRequest request, - @RequestPart(value = "images", required = false) final List images, - @UserId final Long userId) { + public BaseResponse createFeed( + @RequestPart("request") @Valid final FeedCreateRequest request, + @Parameter(description = "피드에 첨부할 이미지 파일들") @RequestPart(value = "images", required = false) final List images, + @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(FeedIdResponse.of(feedCreateUseCase.createFeed(request.toCommand(userId),images))); } - // 피드 수정 (책 빼고 변경가능) + @Operation( + summary = "피드 수정 (책 빼고 변경가능)", + description = "사용자가 피드를 수정합니다." + ) + @ExceptionDescription(FEED_UPDATE) @PatchMapping("/feeds/{feedId}") - public BaseResponse updateFeed(@RequestBody @Valid final FeedUpdateRequest request, - @PathVariable("feedId") final Long feedId, - @UserId final Long userId) { + public BaseResponse updateFeed( + @RequestBody @Valid final FeedUpdateRequest request, + @Parameter(description = "수정할 피드 ID") @PathVariable("feedId") final Long feedId, + @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(FeedIdResponse.of(feedUpdateUseCase.updateFeed(request.toCommand(userId,feedId)))); } - //피드 저장상태 변경: true -> 저장, false -> 저장해제(삭제) + @Operation( + summary = "피드 저장 상태 변경", + description = "사용자가 피드의 저장 상태를 변경합니다. 저장: true, 저장해제(삭제): false" + ) + @ExceptionDescription(CHANGE_FEED_SAVED_STATE) @PostMapping("/feeds/{feedId}/saved") - public BaseResponse changeSavedFeed(@RequestBody final FeedIsSavedRequest request, - @PathVariable("feedId") final Long feedId, - @UserId final Long userId) { + public BaseResponse changeSavedFeed( + @RequestBody final FeedIsSavedRequest request, + @Parameter(description = "저장 상태 변경하려는 피드 ID") @PathVariable("feedId") final Long feedId, + @Parameter(hidden = true) @UserId final Long userId) { return BaseResponse.ok(FeedIsSavedResponse.of(feedSavedUseCase.changeSavedFeed(FeedIsSavedRequest.toCommand(userId,feedId,request.type())))); } diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java index 405c65aef..239b5154c 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java @@ -1,5 +1,8 @@ package konkuk.thip.feed.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.feed.adapter.in.web.response.FeedShowAllResponse; @@ -9,15 +12,21 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Feed Query API", description = "피드 조회 관련 API") @RestController @RequiredArgsConstructor public class FeedQueryController { private final FeedShowAllUseCase feedShowAllUseCase; + @Operation( + summary = "피드 전체 조회", + description = "사용자가 작성한 피드를 전체 조회합니다." + ) @GetMapping("/feeds") public BaseResponse showAllFeeds( - @UserId final Long userId, + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") @RequestParam(value = "cursor", required = false) final String cursor) { return BaseResponse.ok(feedShowAllUseCase.showAllFeeds(userId, cursor)); } diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java index a4a518638..80e3cc92b 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedCreateRequest.java @@ -1,22 +1,28 @@ package konkuk.thip.feed.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import konkuk.thip.feed.application.port.in.dto.FeedCreateCommand; import java.util.List; +@Schema(description = "피드 생성 요청 DTO") public record FeedCreateRequest( + @Schema(description = "생성할 피드의 책 ISBN", example = "9780306406157") @NotBlank(message = "ISBN은 필수입니다.") String isbn, + @Schema(description = "피드 내용", example = "이 책은 정말 좋습니다!") @NotBlank(message = "콘텐츠 내용은 필수입니다.") String contentBody, + @Schema(description = "방 공개 설정 여부 (true: 공개, false: 비공개)", example = "true") @NotNull(message = "방 공개 설정 여부는 필수입니다.") Boolean isPublic, + @Schema(description = "피드에 추가할 태그들", example = "[\"한국소설\", \"외국소설\", \"시\"]") List tagList ) { public FeedCreateCommand toCommand(Long userId) { diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java index fb0f290e0..8615e809e 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/request/FeedIsSavedRequest.java @@ -1,9 +1,12 @@ package konkuk.thip.feed.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import konkuk.thip.feed.application.port.in.dto.FeedIsSavedCommand; +@Schema(description = "피드 저장 상태 요청 DTO") public record FeedIsSavedRequest( + @Schema(description = "저장 상태 종류 (저장하기: true, 저장취소: false)", example = "true") @NotNull(message = "type은 필수입니다.") Boolean type ) { diff --git a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java index 8e181f225..32ad51287 100644 --- a/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java +++ b/src/main/java/konkuk/thip/post/application/port/out/PostLikeQueryPort.java @@ -5,5 +5,4 @@ public interface PostLikeQueryPort { Set findPostIdsLikedByUser(Set postIds, Long userId); - } diff --git a/src/main/java/konkuk/thip/record/adapter/in/web/RecordCommandController.java b/src/main/java/konkuk/thip/record/adapter/in/web/RecordCommandController.java index c041b3737..dfca9e812 100644 --- a/src/main/java/konkuk/thip/record/adapter/in/web/RecordCommandController.java +++ b/src/main/java/konkuk/thip/record/adapter/in/web/RecordCommandController.java @@ -1,24 +1,40 @@ package konkuk.thip.record.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.record.adapter.in.web.request.RecordCreateRequest; import konkuk.thip.record.adapter.in.web.response.RecordCreateResponse; import konkuk.thip.record.application.port.in.RecordCreateUseCase; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.*; +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.RestController; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.*; + +@Tag(name = "Record Command API", description = "기록 상태변경 관련 API") @RestController @RequiredArgsConstructor public class RecordCommandController { private final RecordCreateUseCase recordCreateUseCase; + @Operation( + summary = "기록 생성", + description = "방에 대한 기록을 생성합니다." + ) + @ExceptionDescription(RECORD_CREATE) @PostMapping("/rooms/{roomId}/record") public BaseResponse createRecord( - @UserId final Long userId, - @PathVariable final Long roomId, - @Valid @RequestBody final RecordCreateRequest recordCreateRequest) { + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "기록을 생성할 방 ID", example = "1") @PathVariable final Long roomId, + @Valid @RequestBody final RecordCreateRequest recordCreateRequest + ) { return BaseResponse.ok( RecordCreateResponse.of( recordCreateUseCase.createRecord(recordCreateRequest.toCommand(roomId, userId)) diff --git a/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java b/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java index 1b117774c..4408f1dee 100644 --- a/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java +++ b/src/main/java/konkuk/thip/record/adapter/in/web/RecordQueryController.java @@ -1,7 +1,11 @@ package konkuk.thip.record.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.record.adapter.in.web.response.RecordSearchResponse; import konkuk.thip.record.application.port.in.dto.RecordSearchQuery; import konkuk.thip.record.application.port.in.dto.RecordSearchUseCase; @@ -11,35 +15,38 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.RECORD_SEARCH; + +@Tag(name = "Record Query API", description = "기록 조회 관련 API") @RestController @RequiredArgsConstructor public class RecordQueryController { private final RecordSearchUseCase recordSearchUseCase; - /** - * 방의 게시글(기록, 투표) 목록 조회 - * @param roomId - * @param type : group , mine - * @param sort : 그룹 기록 -> 최신순 / 내 기록 -> 페이지 높은 순 default 정렬 - * @param pageStart - * @param pageEnd - * @param isOverview : 총평보기 필터 여부 - * @param isPageFilter : 페이지 필터 여부 - * @param userId - * @return - */ + @Operation( + summary = "방의 게시글(기록, 투표) 목록 조회", + description = "방의 게시글(기록, 투표) 목록을 조회합니다. type에 따라 그룹 기록 또는 내 기록을 조회할 수 있습니다." + ) + @ExceptionDescription(RECORD_SEARCH) @GetMapping("/rooms/{roomId}/posts") public BaseResponse viewRecordList( - @PathVariable final Long roomId, + @Parameter(description = "게시글을 조회할 방 ID", example = "1") @PathVariable final Long roomId, + @Parameter(description = "게시글 조회 타입 (group: 그룹 기록, mine: 내 기록)", example = "group") @RequestParam(required = false, defaultValue = "group") final String type, + @Parameter(description = "게시글 정렬 기준 (최신순: latest, 인기 순: like, 댓글 많은 순: comment) \n그룹 기록에서만 사용됩니다. 내 기록에서는 페이지 높은 순 고정 정렬", example = "latest") @RequestParam(required = false) final String sort, + @Parameter(description = "게시글 페이지 필터링 시작 범위 (default: 0)", example = "10") @RequestParam(required = false) final Integer pageStart, + @Parameter(description = "게시글 페이지 필터링 종료 범위 (default: 책의 마지막 페이지)", example = "100") @RequestParam(required = false) final Integer pageEnd, + @Parameter(description = "총평 보기 필터 여부 (default: false)", example = "true") @RequestParam(required = false, defaultValue = "false") final Boolean isOverview, + @Parameter(description = "페이지 필터 여부 (default: false)", example = "true") @RequestParam(required = false, defaultValue = "false") final Boolean isPageFilter, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") @RequestParam(required = false) final String cursor, - @UserId final Long userId + @Parameter(hidden = true) @UserId final Long userId ) { return BaseResponse.ok(recordSearchUseCase.search( RecordSearchQuery.builder() diff --git a/src/main/java/konkuk/thip/record/adapter/in/web/request/RecordCreateRequest.java b/src/main/java/konkuk/thip/record/adapter/in/web/request/RecordCreateRequest.java index 856061bd0..d0086eb84 100644 --- a/src/main/java/konkuk/thip/record/adapter/in/web/request/RecordCreateRequest.java +++ b/src/main/java/konkuk/thip/record/adapter/in/web/request/RecordCreateRequest.java @@ -1,20 +1,27 @@ package konkuk.thip.record.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import konkuk.thip.record.application.port.in.dto.RecordCreateCommand; +@Schema( + description = "기록 생성 요청 DTO" +) public record RecordCreateRequest ( - @NotNull(message = "page는 필수입니다.") - Integer page, + @Schema(description = "기록을 생성할 책의 페이지 번호", example = "20") + @NotNull(message = "page는 필수입니다.") + Integer page, - @NotNull(message = "isOverview(= 총평 여부)는 필수입니다.") - Boolean isOverview, + @Schema(description = "총평 여부", example = "true") + @NotNull(message = "isOverview(= 총평 여부)는 필수입니다.") + Boolean isOverview, - @NotBlank(message = "기록 내용은 필수입니다.") - @Size(max = 500, message = "기록 내용은 최대 500자 입니다.") - String content + @Schema(description = "기록 내용", example = "띱은 최고의 서비스인 것 같습니다.") + @NotBlank(message = "기록 내용은 필수입니다.") + @Size(max = 500, message = "기록 내용은 최대 500자 입니다.") + String content ) { public RecordCreateCommand toCommand(Long roomId, Long creatorId) { return new RecordCreateCommand( diff --git a/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java b/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java index 67ea21dac..ce4dfc2f6 100644 --- a/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java +++ b/src/main/java/konkuk/thip/record/application/service/RecordSearchService.java @@ -78,7 +78,7 @@ public RecordSearchResponse search(RecordSearchQuery recordSearchQuery) { pageEnd = book.getPageCount(); } } - yield getGroupRecordBySortParams(recordSearchQuery.sort(), roomId, userId, cursor, pageStart, pageEnd, isPageFilter, isOverview); + yield getGroupRecordBySortParams(recordSearchQuery.sort(), roomId, userId, cursor, pageStart, pageEnd, isOverview); } case MINE -> { validateMyRecordFilters(pageStart, pageEnd, isPageFilter, isOverview, recordSearchQuery.sort()); @@ -110,7 +110,7 @@ public RecordSearchResponse search(RecordSearchQuery recordSearchQuery) { .build(); } - private CursorBasedList getGroupRecordBySortParams(String sort, Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isPageFilter, Boolean isOverview) { + private CursorBasedList getGroupRecordBySortParams(String sort, Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview) { return switch(RecordSearchSortParams.from(sort)) { case LATEST -> recordQueryPort.searchGroupRecordsByLatest(roomId, userId, cursor, pageStart, pageEnd, isOverview); case LIKE -> recordQueryPort.searchGroupRecordsByLike(roomId, userId, cursor, pageStart, pageEnd, isOverview); diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java index e346938b4..2624313d3 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java @@ -1,8 +1,12 @@ package konkuk.thip.room.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.room.adapter.in.web.request.RoomCreateRequest; import konkuk.thip.room.adapter.in.web.request.RoomJoinRequest; import konkuk.thip.room.adapter.in.web.response.RoomCreateResponse; @@ -15,6 +19,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.*; + +@Tag(name = "Room Command API", description = "방 상태변경 관련 API") @RestController @RequiredArgsConstructor public class RoomCommandController { @@ -26,8 +33,16 @@ public class RoomCommandController { /** * 방 생성 요청 */ + @Operation( + summary = "방 생성", + description = "사용자가 방을 생성합니다. 방 생성 시 필요한 정보를 포함한 요청을 받습니다." + ) + @ExceptionDescription(ROOM_CREATE) @PostMapping("/rooms") - public BaseResponse createRoom(@Valid @RequestBody RoomCreateRequest request, @UserId Long userId) { + public BaseResponse createRoom( + @Valid @RequestBody RoomCreateRequest request, + @Parameter(hidden = true) @UserId Long userId + ) { return BaseResponse.ok(RoomCreateResponse.of( roomCreateUseCase.createRoom(request.toCommand(), userId) )); @@ -36,11 +51,17 @@ public BaseResponse createRoom(@Valid @RequestBody RoomCreat /** * 방 참여하기/취소하기 요청 */ + @Operation( + summary = "방 참여 상태 변경", + description = "사용자가 방에 참여하거나 참여를 취소합니다. join -> 방 참여, cancel -> 방 참여 취소" + ) + @ExceptionDescription(ROOM_JOIN_CANCEL) @PostMapping("/rooms/{roomId}/join") - public BaseResponse joinRoom(@Valid @RequestBody final RoomJoinRequest request, - @UserId final Long userId, - @PathVariable final Long roomId) { - + public BaseResponse joinRoom( + @Valid @RequestBody final RoomJoinRequest request, + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "참여/취소하려는 방의 ID", example = "1") @PathVariable final Long roomId + ) { roomJoinUsecase.changeJoinState(request.toCommand(userId, roomId)); return BaseResponse.ok(null); } @@ -48,9 +69,15 @@ public BaseResponse joinRoom(@Valid @RequestBody final RoomJoinRequest req /** * 방 모집 마감하기 요청 */ + @Operation( + summary = "방 모집 마감", + description = "방장이 방의 모집을 마감합니다. 방장이 방 모집을 마감할 때 사용합니다." + ) + @ExceptionDescription(ROOM_RECRUIT_CLOSE) @PostMapping("/rooms/{roomId}/close") - public BaseResponse closeRoomRecruit(@UserId final Long userId, - @PathVariable final Long roomId) { + public BaseResponse closeRoomRecruit( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "모집을 마감할 방의 ID", example = "1") @PathVariable final Long roomId) { roomRecruitCloseUsecase.closeRoomRecruit(userId, roomId); return BaseResponse.ok(null); } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java index dd36b6f7e..c16e0c8dd 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java @@ -1,23 +1,22 @@ package konkuk.thip.room.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import konkuk.thip.room.adapter.in.web.request.RoomVerifyPasswordRequest; import konkuk.thip.room.adapter.in.web.response.*; import konkuk.thip.room.application.port.in.*; -import konkuk.thip.room.application.port.in.RoomGetHomeJoinedListUseCase; -import konkuk.thip.room.application.port.in.RoomGetMemberListUseCase; -import konkuk.thip.room.application.port.in.RoomSearchUseCase; -import jakarta.validation.Valid; -import konkuk.thip.room.adapter.in.web.request.RoomVerifyPasswordRequest; import konkuk.thip.room.application.port.in.dto.RoomGetHomeJoinedListQuery; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.*; -import java.time.LocalDate; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.*; +@Tag(name = "Room Query API", description = "방 조회 관련 API") @RestController @RequiredArgsConstructor public class RoomQueryController { @@ -30,49 +29,78 @@ public class RoomQueryController { private final RoomShowPlayingDetailViewUseCase roomShowPlayingDetailViewUseCase; private final RoomShowMineUseCase roomShowMineUseCase; + @Operation( + summary = "방 검색", + description = "키워드, 카테고리, 정렬 방식, 페이지 번호를 기준으로 방을 검색합니다." + ) + @ExceptionDescription(ROOM_SEARCH) @GetMapping("/rooms/search") public BaseResponse searchRooms( - @RequestParam(value = "keyword", required = false, defaultValue = "") final String keyword, - @RequestParam(value = "category", required = false, defaultValue = "") final String category, - @RequestParam("sort") final String sort, - @RequestParam("page") final int page + @Parameter(description = "검색 키워드 (책 이름 or 방 이름", example = "해리") @RequestParam(value = "keyword", required = false, defaultValue = "") final String keyword, + @Parameter(description = "모임방 카테고리", example = "문학") @RequestParam(value = "category", required = false, defaultValue = "") final String category, + @Parameter(description = "정렬 방식 (마감 임박 : deadline, 신청 인원 : memberCount)", example = "deadline") @RequestParam("sort") final String sort, + @Parameter(description = "페이지 번호", example = "1") @RequestParam("page") final int page ) { return BaseResponse.ok(roomSearchUseCase.searchRoom(keyword, category, sort, page)); } - //비공개 방 비밀번호 입력 검증 + @Operation( + summary = "비공개 방 비밀번호 입력 검증", + description = "비공개 방에 참여하기 위해 비밀번호를 검증합니다." + ) + @ExceptionDescription(ROOM_PASSWORD_CHECK) @PostMapping("/rooms/{roomId}/password") - public BaseResponse verifyRoomPassword(@PathVariable("roomId") final Long roomId, - @Valid @RequestBody final RoomVerifyPasswordRequest roomVerifyPasswordRequest - ) { + public BaseResponse verifyRoomPassword( + @Parameter(description = "비밀번호 검증하려는 비공개 방 ID", example = "1") @PathVariable("roomId") final Long roomId, + @Valid @RequestBody final RoomVerifyPasswordRequest roomVerifyPasswordRequest + ) { return BaseResponse.ok(roomVerifyPasswordUseCase.verifyRoomPassword(roomVerifyPasswordRequest.toQuery(roomId))); } - // 모집중인 방 상세보기 + @Operation( + summary = "모집중인 방 상세보기", + description = "모집중인 방의 상세 정보를 조회합니다." + ) + @ExceptionDescription(ROOM_RECRUITING_DETAIL_VIEW) @GetMapping("/rooms/{roomId}/recruiting") public BaseResponse getRecruitingRoomDetailView( - @UserId final Long userId, - @PathVariable("roomId") final Long roomId) { + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "상세보기 하려는 방의 ID", example = "1") @PathVariable("roomId") final Long roomId) { return BaseResponse.ok(roomShowRecruitingDetailViewUseCase.getRecruitingRoomDetailView(userId, roomId)); } - //[모임 홈] 참여중인 내 모임방 조회 + @Operation( + summary = "[모임 홈] 참여중인 내 모임방 조회", + description = "사용자가 참여중인 모임방 목록을 조회합니다." + ) + @ExceptionDescription(ROOM_GET_HOME_JOINED_LIST) @GetMapping("/rooms/home/joined") - public BaseResponse getHomeJoinedRooms(@UserId final Long userId, - @RequestParam("page") final int page) { + public BaseResponse getHomeJoinedRooms( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "페이지 번호", example = "1") @RequestParam("page") final int page) { return BaseResponse.ok(roomGetHomeJoinedListUseCase.getHomeJoinedRoomList( RoomGetHomeJoinedListQuery.builder() .userId(userId) .page(page).build())); } - // 독서메이트 조회 + @Operation( + summary = "독서메이트(방 참여자) 조회", + description = "특정 방의 참여자 목록을 조회합니다." + ) + @ExceptionDescription(ROOM_GET_MEMBER_LIST) @GetMapping("/rooms/{roomId}/users") - public BaseResponse getRoomMemberList(@PathVariable("roomId") final Long roomId){ + public BaseResponse getRoomMemberList( + @Parameter(description = "방 참여자 목록을 조회하려는 방의 ID", example = "1") @PathVariable("roomId") final Long roomId){ return BaseResponse.ok(roomGetMemberListUseCase.getRoomMemberList(roomId)); } // 진행중인 방 상세보기 + @Operation( + summary = "진행중인 방 상세보기", + description = "진행중인 방의 상세 정보를 조회합니다." + ) + @ExceptionDescription(ROOM_PLAYING_DETAIL) @GetMapping("/rooms/{roomId}/playing") public BaseResponse getPlayingRoomDetailView( @UserId final Long userId, @@ -82,10 +110,16 @@ public BaseResponse getPlayingRoomDetailView( } // 내 모임방 리스트 조회 + @Operation( + summary = "내 모임방 리스트 조회", + description = "사용자가 참여중인 모임방 목록을 조회합니다. 타입에 따라 모집중인 방과 진행중인 방을 구분할 수 있습니다." + ) @GetMapping("/rooms/my") public BaseResponse getMyRooms( - @UserId final Long userId, + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "조회할 방의 타입 (playingAndRecruiting: 진행중인 방과 모집중인 방, recruiting: 모집중인 방만, playing: 진행중인 방만, expired: 만료된 방만)", example = "playingAndRecruiting") @RequestParam(value = "type", required = false, defaultValue = "playingAndRecruiting") final String type, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") @RequestParam(value = "cursor", required = false) final String cursor) { return BaseResponse.ok(roomShowMineUseCase.getMyRooms(userId, type, cursor)); } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomCreateRequest.java b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomCreateRequest.java index fb025c30f..90a131a4a 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomCreateRequest.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomCreateRequest.java @@ -1,5 +1,6 @@ package konkuk.thip.room.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; import jakarta.validation.constraints.*; import konkuk.thip.room.application.port.in.dto.RoomCreateCommand; @@ -7,39 +8,49 @@ import java.time.LocalDate; import java.time.format.DateTimeFormatter; +@Schema(description = "방 생성 요청 DTO") public record RoomCreateRequest( + @Schema(description = "모임방에서 기록 공유할 책의 ISBN", example = "9788936433862") @NotBlank(message = "ISBN은 필수입니다.") String isbn, + @Schema(description = "모임방 카테고리", example = "문학") @NotBlank(message = "카테고리는 필수입니다.") String category, + @Schema(description = "모임방 이름", example = "문학 모임") @NotBlank(message = "방 이름은 필수입니다.") String roomName, + @Schema(description = "모임방 설명", example = "문학을 사랑하는 사람들의 모임입니다.") @NotBlank(message = "설명은 필수입니다.") String description, + @Schema(description = "진행 시작일", example = "2023.10.01") @Pattern( regexp = "\\d{4}\\.\\d{2}\\.\\d{2}", message = "진행 시작일은 yyyy.MM.dd 형식이어야 합니다." ) String progressStartDate, + @Schema(description = "진행 종료일", example = "2023.10.31") @Pattern( regexp = "\\d{4}\\.\\d{2}\\.\\d{2}", message = "진행 종료일은 yyyy.MM.dd 형식이어야 합니다." ) String progressEndDate, + @Schema(description = "모집 인원 (1~30)", example = "5") @Min(value = 1, message = "모집 인원은 최소 1명이어야 합니다.") @Max(value = 30, message = "모집 인원은 최대 30명이어야 합니다.") int recruitCount, + @Schema(description = "방 비밀번호 (숫자 4자리)", example = "1234") @Nullable @Pattern(regexp = "\\d{4}", message = "비밀번호는 숫자 4자리여야 합니다.") String password, + @Schema(description = "방 공개 설정 여부 (true: 공개, false: 비공개)", example = "true") @NotNull(message = "방 공개 설정 여부는 필수입니다.") Boolean isPublic ) { diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomJoinRequest.java b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomJoinRequest.java index baf6d3f0d..0b95ec582 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomJoinRequest.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomJoinRequest.java @@ -1,9 +1,12 @@ package konkuk.thip.room.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import konkuk.thip.room.application.port.in.dto.RoomJoinCommand; +@Schema(description = "방 참여/취소 요청 DTO") public record RoomJoinRequest( + @Schema(description = "방 참여 유형 (join: 참여, cancel: 취소)", example = "join") @NotBlank(message = "방 참여 유형 파라미터는 필수입니다.") String type ) { diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomVerifyPasswordRequest.java b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomVerifyPasswordRequest.java index 153aae0e7..39acc1dbe 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomVerifyPasswordRequest.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomVerifyPasswordRequest.java @@ -1,10 +1,13 @@ package konkuk.thip.room.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; import konkuk.thip.room.application.port.in.dto.RoomVerifyPasswordQuery; +@Schema(description = "비공개 방 비밀번호 입력 검증 요청 DTO") public record RoomVerifyPasswordRequest( + @Schema(description = "방 비밀번호 (숫자 4자리)", example = "1234") @Pattern(regexp = "\\d{4}", message = "비밀번호는 숫자 4자리여야 합니다.") String password ) { diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java index e7b011ef2..cd984e359 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java @@ -1,20 +1,21 @@ package konkuk.thip.user.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.Oauth2Id; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.user.adapter.in.web.request.UserFollowRequest; import konkuk.thip.user.adapter.in.web.request.UserSignupRequest; -import konkuk.thip.user.adapter.in.web.request.UserVerifyNicknameRequest; import konkuk.thip.user.adapter.in.web.response.UserFollowResponse; import konkuk.thip.user.adapter.in.web.response.UserSignupResponse; -import konkuk.thip.user.adapter.in.web.response.UserVerifyNicknameResponse; import konkuk.thip.user.application.port.in.UserFollowUsecase; import konkuk.thip.user.application.port.in.UserSignupUseCase; -import konkuk.thip.user.application.port.in.UserVerifyNicknameUseCase; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -23,22 +24,26 @@ import static konkuk.thip.common.security.constant.AuthParameters.JWT_HEADER_KEY; import static konkuk.thip.common.security.constant.AuthParameters.JWT_PREFIX; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.CHANGE_FOLLOW_STATE; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.USER_SIGNUP; +@Tag(name = "User Command API", description = "사용자가 주체가 되는 정보 수정") @RestController @RequiredArgsConstructor public class UserCommandController { private final UserSignupUseCase userSignupUseCase; - private final UserVerifyNicknameUseCase userVerifyNicknameUseCase; private final UserFollowUsecase userFollowUsecase; private final JwtUtil jwtUtil; - /** - * 사용자 회원가입 - */ + @Operation( + summary = "사용자 회원가입", + description = "사용자가 회원가입을 합니다. OAuth2 ID를 통해 사용자를 식별합니다." + ) + @ExceptionDescription(USER_SIGNUP) @PostMapping("/users/signup") public BaseResponse signup(@Valid @RequestBody final UserSignupRequest request, - @Oauth2Id final String oauth2Id, + @Parameter(hidden = true) @Oauth2Id final String oauth2Id, HttpServletResponse response) { Long userId = userSignupUseCase.signup(request.toCommand(oauth2Id)); String accessToken = jwtUtil.createAccessToken(userId); @@ -46,23 +51,16 @@ public BaseResponse signup(@Valid @RequestBody final UserSig return BaseResponse.ok(UserSignupResponse.of(userId)); } - /** - * 닉네임 중복 확인 - */ - @PostMapping("/users/nickname") - public BaseResponse verifyNickname(@Valid @RequestBody final UserVerifyNicknameRequest request) { - return BaseResponse.ok(UserVerifyNicknameResponse.of( - userVerifyNicknameUseCase.isNicknameUnique(request.nickname())) - ); - } - - /** - * 사용자 팔로우 상태 변경 : true -> 팔로우, false -> 언팔로우 - */ + @Operation( + summary = "사용자 팔로우 상태 변경", + description = "특정 사용자를 팔로우하거나 언팔로우합니다. true 이면 팔로우, false 이면 언팔로우입니다." + ) + @ExceptionDescription(CHANGE_FOLLOW_STATE) @PostMapping("/users/following/{followingUserId}") - public BaseResponse followUser(@UserId final Long userId, - @PathVariable final Long followingUserId, - @RequestBody @Valid final UserFollowRequest request) { + public BaseResponse followUser( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "팔로우/언팔로우할 사용자 ID") @PathVariable final Long followingUserId, + @RequestBody @Valid final UserFollowRequest request) { return BaseResponse.ok(UserFollowResponse.of(userFollowUsecase.changeFollowingState( UserFollowRequest.toCommand(userId, followingUserId, request.type()) ))); diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java index 2fd599ebd..490538119 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java @@ -1,22 +1,30 @@ package konkuk.thip.user.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import konkuk.thip.user.adapter.in.web.request.UserVerifyNicknameRequest; import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; +import konkuk.thip.user.adapter.in.web.response.UserVerifyNicknameResponse; import konkuk.thip.user.adapter.in.web.response.UserIsFollowingResponse; import konkuk.thip.user.adapter.in.web.response.UserViewAliasChoiceResponse; import konkuk.thip.user.application.port.in.UserGetFollowUsecase; +import konkuk.thip.user.application.port.in.UserVerifyNicknameUseCase; import konkuk.thip.user.application.port.in.UserIsFollowingUsecase; import konkuk.thip.user.application.port.in.UserViewAliasChoiceUseCase; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import static konkuk.thip.common.swagger.SwaggerResponseDescription.GET_USER_FOLLOW; + +@Tag(name = "User Query API", description = "사용자가 주체가 되는 조회") @RestController @RequiredArgsConstructor public class UserQueryController { @@ -24,10 +32,23 @@ public class UserQueryController { private final UserViewAliasChoiceUseCase userViewAliasChoiceUseCase; private final UserGetFollowUsecase userGetFollowUsecase; private final UserIsFollowingUsecase userIsFollowingUsecase; + private final UserVerifyNicknameUseCase userVerifyNicknameUseCase; + + @Operation( + summary = "닉네임 중복 확인", + description = "사용자가 입력한 닉네임이 중복되는지 확인합니다." + ) + @PostMapping("/users/nickname") + public BaseResponse verifyNickname(@Valid @RequestBody final UserVerifyNicknameRequest request) { + return BaseResponse.ok(UserVerifyNicknameResponse.of( + userVerifyNicknameUseCase.isNicknameUnique(request.nickname())) + ); + } - /** - * 사용자 별칭 선택 화면 조회 - */ + @Operation( + summary = "사용자 별칭 선택 화면 조회", + description = "사용자가 별칭을 선택할 수 있는 화면을 조회합니다." + ) @GetMapping("/users/alias") public BaseResponse showAliasChoiceView() { return BaseResponse.ok(UserViewAliasChoiceResponse.of( @@ -35,32 +56,42 @@ public BaseResponse showAliasChoiceView() { )); } - /** - * 사용자 팔로워 조회 - */ + @Operation( + summary = "사용자 팔로워 조회", + description = "특정 사용자의 팔로워 목록을 조회합니다." + ) + @ExceptionDescription(GET_USER_FOLLOW) @GetMapping("/users/{userId}/followers") - public BaseResponse showFollowers(@PathVariable final Long userId, - @RequestParam(required = false) final String cursor, - @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { + public BaseResponse showFollowers( + @Parameter(description = "조회할 사용자 ID") @PathVariable final Long userId, + @Parameter(description = "커서") @RequestParam(required = false) final String cursor, + @Parameter(description = "단일 요청 페이지 크기 (1~10)") + @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { return BaseResponse.ok(userGetFollowUsecase.getUserFollowers(userId, cursor, size)); } - /** - * 내 팔로잉 리스트 조회 - */ + @Operation( + summary = "내 팔로잉 조회", + description = "내가 팔로우하는 사용자 목록을 조회합니다." + ) + @ExceptionDescription(GET_USER_FOLLOW) @GetMapping("/users/my/following") - public BaseResponse showMyFollowing(@UserId final Long userId, - @RequestParam(required = false) final String cursor, - @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { + public BaseResponse showMyFollowing( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "커서") @RequestParam(required = false) final String cursor, + @Parameter(description = "단일 요청 페이지 크기 (1~10)") + @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { return BaseResponse.ok(userGetFollowUsecase.getMyFollowing(userId, cursor, size)); } - /** - * 팔로잉 여부 조회 - */ + @Operation( + summary = "팔로잉 여부 조회", + description = "특정 사용자가 다른 사용자를 팔로우하고 있는지 확인합니다." + ) @GetMapping("/users/{targetUserId}/is-following") - public BaseResponse checkisFollowing(@UserId final Long userId, - @PathVariable final Long targetUserId) { + public BaseResponse checkIsFollowing( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "팔로우 여부를 확인할 대상 사용자 ID") @PathVariable final Long targetUserId) { return BaseResponse.ok(UserIsFollowingResponse.of(userIsFollowingUsecase.isFollowing(userId, targetUserId))); } } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserFollowRequest.java b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserFollowRequest.java index 48f4d916d..edbe12846 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserFollowRequest.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserFollowRequest.java @@ -1,11 +1,14 @@ package konkuk.thip.user.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import konkuk.thip.user.application.port.in.dto.UserFollowCommand; +@Schema(description = "사용자 팔로우 상태 변경 요청 DTO") public record UserFollowRequest( + @Schema(description = "true -> 팔로우, false -> 언팔로우", example = "true") @NotNull(message = "type은 필수 파라미터입니다.") - Boolean type // true -> 팔로우, false -> 언팔로우 + Boolean type ) { public static UserFollowCommand toCommand(Long userId, Long targetUserId, Boolean type) { return new UserFollowCommand(userId, targetUserId, type); diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserSignupRequest.java b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserSignupRequest.java index fbe40ac17..c29515d1d 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserSignupRequest.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserSignupRequest.java @@ -1,14 +1,18 @@ package konkuk.thip.user.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import konkuk.thip.user.application.port.in.dto.UserSignupCommand; +@Schema(description = "사용자 회원가입 요청 DTO") public record UserSignupRequest( + @Schema(description = "사용자 칭호", example = "문학가") @NotBlank(message = "aliasName은 필수입니다.") String aliasName, + @Schema(description = "사용자 닉네임", example = "홍길동_123") @Pattern(regexp = "[가-힣a-zA-Z0-9]+", message = "닉네임은 한글, 영어, 숫자로만 구성되어야 합니다.(공백불가)") @Size(max = 10, message = "닉네임은 최대 10자 입니다.") String nickname diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserVerifyNicknameRequest.java b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserVerifyNicknameRequest.java index 54050a79c..733fb7cf4 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserVerifyNicknameRequest.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserVerifyNicknameRequest.java @@ -1,9 +1,12 @@ package konkuk.thip.user.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +@Schema(description = "사용자 닉네임 중복 확인 요청 DTO") public record UserVerifyNicknameRequest( + @Schema(description = "사용자 닉네임", example = "홍길동_123") @Pattern(regexp = "[가-힣a-zA-Z0-9]+", message = "닉네임은 한글, 영어, 숫자로만 구성되어야 합니다.(공백불가)") @Size(max = 10, message = "닉네임은 최대 10자 입니다.") String nickname diff --git a/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java b/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java index 2ed5025d5..b28ebb890 100644 --- a/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java +++ b/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java @@ -1,5 +1,8 @@ package konkuk.thip.vote.adapter.in.web; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; @@ -12,16 +15,21 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Vote Command API", description = "투표 상태변경 관련 API") @RestController @RequiredArgsConstructor public class VoteCommandController { private final VoteCreateUseCase voteCreateUseCase; + @Operation( + summary = "투표 생성", + description = "방에 대한 투표를 생성합니다." + ) @PostMapping("/rooms/{roomId}/vote") public BaseResponse createVote( - @UserId Long userId, - @PathVariable Long roomId, + @Parameter(hidden = true) @UserId Long userId, + @Parameter(description = "투표를 생성할 방 ID", example = "1") @PathVariable Long roomId, @Valid @RequestBody VoteCreateRequest request) { return BaseResponse.ok(VoteCreateResponse.of( diff --git a/src/main/java/konkuk/thip/vote/adapter/in/web/VoteQueryController.java b/src/main/java/konkuk/thip/vote/adapter/in/web/VoteQueryController.java index ca617d8c9..2557f95fb 100644 --- a/src/main/java/konkuk/thip/vote/adapter/in/web/VoteQueryController.java +++ b/src/main/java/konkuk/thip/vote/adapter/in/web/VoteQueryController.java @@ -1,8 +1,10 @@ package konkuk.thip.vote.adapter.in.web; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Vote Query API", description = "투표 조회 관련 API") @RestController @RequiredArgsConstructor public class VoteQueryController { diff --git a/src/main/java/konkuk/thip/vote/adapter/in/web/request/VoteCreateRequest.java b/src/main/java/konkuk/thip/vote/adapter/in/web/request/VoteCreateRequest.java index 425f51517..f9e720f7d 100644 --- a/src/main/java/konkuk/thip/vote/adapter/in/web/request/VoteCreateRequest.java +++ b/src/main/java/konkuk/thip/vote/adapter/in/web/request/VoteCreateRequest.java @@ -1,5 +1,6 @@ package konkuk.thip.vote.adapter.in.web.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -8,23 +9,31 @@ import java.util.List; +@Schema(description = "투표 생성 요청 DTO") public record VoteCreateRequest( + @Schema(description = "투표를 생성할 책의 페이지 번호", example = "20") @NotNull(message = "page는 필수입니다.") Integer page, + @Schema(description = "총평 여부", example = "true") @NotNull(message = "isOverview(= 총평 여부)는 필수입니다.") Boolean isOverview, + @Schema(description = "투표 내용", example = "띱은 최고의 서비스인가?") @NotBlank(message = "투표 내용은 필수입니다.") @Size(max = 20, message = "투표 내용은 최대 20자 입니다.") String content, + @Schema(description = "투표 항목 리스트", example = "[{\"itemName\": \"네\"}, {\"itemName\": \"아니오\"}]") @NotNull(message = "투표 항목은 필수입니다.") @Size(min = 1, max = 5, message = "투표 항목은 1개 이상, 최대 5개까지입니다.") @Valid List voteItemList ) { + @Schema(description = "투표 항목 DTO") public record VoteItemCreateRequest( + + @Schema(description = "투표 항목 이름", example = "네") @NotBlank(message = "투표 항목 이름은 필수입니다.") @Size(max = 20, message = "투표 항목 이름은 최대 20자입니다.") String itemName