From 4e748180471147780b0df7f28987862f3ed4dd61 Mon Sep 17 00:00:00 2001 From: smiinii Date: Tue, 25 Aug 2026 15:40:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Swagger=20API=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/README.md | 15 +++ backend/build.gradle.kts | 1 + .../chalkak/backend/config/OpenApiConfig.java | 55 +++++++++ .../api/v1/controller/PostController.java | 5 +- .../backend/post/api/v1/docs/PostApiDocs.java | 83 +++++++++++++ .../api/v1/dto/request/PostListRequest.java | 14 +++ .../v1/dto/response/PostDetailResponse.java | 2 + .../api/v1/dto/response/PostListResponse.java | 7 ++ .../api/v1/controller/TopicController.java | 4 +- .../topic/api/v1/docs/TopicApiDocs.java | 47 ++++++++ ...SignatureProcessingCallbackController.java | 6 +- .../SignatureProcessingCallbackApiDocs.java | 96 +++++++++++++++ .../api/v1/controller/UserController.java | 6 +- .../backend/user/api/v1/docs/UserApiDocs.java | 111 ++++++++++++++++++ .../request/UserSignatureUpdateRequest.java | 5 + .../src/main/resources/application-prod.yml | 6 + backend/src/main/resources/application.yml | 7 ++ 17 files changed, 466 insertions(+), 4 deletions(-) create mode 100644 backend/src/main/java/com/chalkak/backend/config/OpenApiConfig.java create mode 100644 backend/src/main/java/com/chalkak/backend/post/api/v1/docs/PostApiDocs.java create mode 100644 backend/src/main/java/com/chalkak/backend/topic/api/v1/docs/TopicApiDocs.java create mode 100644 backend/src/main/java/com/chalkak/backend/user/api/internal/v1/docs/SignatureProcessingCallbackApiDocs.java create mode 100644 backend/src/main/java/com/chalkak/backend/user/api/v1/docs/UserApiDocs.java diff --git a/backend/README.md b/backend/README.md index 473a282c..53b57494 100644 --- a/backend/README.md +++ b/backend/README.md @@ -9,6 +9,7 @@ - [AI 하네스](#ai-하네스) - [자주 사용하는 명령](#자주-사용하는-명령) - [프로필](#프로필) +- [API 문서](#api-문서) - [임시 인증](#임시-인증) - [이미지 저장소](#이미지-저장소) - [DB 스키마](#db-스키마) @@ -159,6 +160,20 @@ SPRING_PROFILES_ACTIVE=local ./gradlew bootRun > `prod` 프로필은 배포 환경 전용이므로 로컬에서 실행하지 않는다. 원격 DB에 Flyway 마이그레이션이 적용될 수 있다. > `application-prod.yml`에는 DB 접속 정보의 기본값이 없으므로 필수 환경변수가 누락되면 애플리케이션이 기동되지 않는다. +## API 문서 + +로컬·개발 환경의 Swagger UI는 `http://localhost:8080/swagger-ui.html`에서 확인한다. 첫 화면은 `user-api` 그룹으로 연다. + +| 그룹 | 포함 경로 | +|---|---| +| `user-api` | `/api/v1/**` 중 `/api/v1/admin/**` 제외 | +| `admin-api` | `/api/v1/admin/**` | +| `internal-api` | `/internal/v1/**` | + +운영 환경에서는 API 문서 JSON과 Swagger UI를 모두 비활성화한다. + +Spring Boot 4 지원과 최신 기능을 위해 `springdoc-openapi` 3.1.0을 유지한다. 다만 이 버전은 Bean Validation 제약이 붙은 숫자 파라미터를 문서화할 때 경고 로그를 출력하는 [알려진 회귀 문제](https://github.com/springdoc/springdoc-openapi/issues/3314)가 있다. 현재 문서 응답과 스키마 생성에는 문제가 없으므로 하위 버전으로 내리지 않고, [수정 PR](https://github.com/springdoc/springdoc-openapi/pull/3315)이 반영된 정식 버전이 나오면 업그레이드한다. + ## 임시 인증 로그인 사용자는 `X-User-Id` 헤더로 식별한다. diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index 65537533..7d1fda42 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.0") implementation("org.flywaydb:flyway-database-postgresql") runtimeOnly("org.postgresql:postgresql") diff --git a/backend/src/main/java/com/chalkak/backend/config/OpenApiConfig.java b/backend/src/main/java/com/chalkak/backend/config/OpenApiConfig.java new file mode 100644 index 00000000..cb8124fa --- /dev/null +++ b/backend/src/main/java/com/chalkak/backend/config/OpenApiConfig.java @@ -0,0 +1,55 @@ +package com.chalkak.backend.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@OpenAPIDefinition( + info = @Info( + title = "Chalkak API", + version = "v1", + description = "Chalkak 사용자, 운영자 및 내부 API 문서" + ) +) +@SecurityScheme( + name = "userIdHeader", + type = SecuritySchemeType.APIKEY, + in = SecuritySchemeIn.HEADER, + paramName = "X-User-Id", + description = "로컬·개발 환경에서 로그인 사용자를 식별하는 임시 헤더" +) +@Configuration(proxyBeanMethods = false) +@Profile("!prod") +public class OpenApiConfig { + + @Bean + public GroupedOpenApi userApi() { + return GroupedOpenApi.builder() + .group("user-api") + .pathsToMatch("/api/v1/**") + .pathsToExclude("/api/v1/admin/**") + .build(); + } + + @Bean + public GroupedOpenApi adminApi() { + return GroupedOpenApi.builder() + .group("admin-api") + .pathsToMatch("/api/v1/admin/**") + .build(); + } + + @Bean + public GroupedOpenApi internalApi() { + return GroupedOpenApi.builder() + .group("internal-api") + .pathsToMatch("/internal/v1/**") + .build(); + } +} diff --git a/backend/src/main/java/com/chalkak/backend/post/api/v1/controller/PostController.java b/backend/src/main/java/com/chalkak/backend/post/api/v1/controller/PostController.java index fa9b168a..3e4b8a98 100644 --- a/backend/src/main/java/com/chalkak/backend/post/api/v1/controller/PostController.java +++ b/backend/src/main/java/com/chalkak/backend/post/api/v1/controller/PostController.java @@ -1,6 +1,7 @@ package com.chalkak.backend.post.api.v1.controller; import com.chalkak.backend.common.util.CanonicalUuidParser; +import com.chalkak.backend.post.api.v1.docs.PostApiDocs; import com.chalkak.backend.post.api.v1.dto.request.PostListRequest; import com.chalkak.backend.post.api.v1.dto.response.PostDetailResponse; import com.chalkak.backend.post.api.v1.dto.response.PostListResponse; @@ -19,10 +20,11 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/posts") -public class PostController { +public class PostController implements PostApiDocs { private final PostService postService; + @Override @GetMapping public ResponseEntity getPosts( @Valid @ModelAttribute PostListRequest request @@ -40,6 +42,7 @@ public ResponseEntity getPosts( ); } + @Override @GetMapping("/{postId}") public ResponseEntity getPost(@PathVariable String postId) { UUID parsedPostId = CanonicalUuidParser.parse(postId); diff --git a/backend/src/main/java/com/chalkak/backend/post/api/v1/docs/PostApiDocs.java b/backend/src/main/java/com/chalkak/backend/post/api/v1/docs/PostApiDocs.java new file mode 100644 index 00000000..f237c8d0 --- /dev/null +++ b/backend/src/main/java/com/chalkak/backend/post/api/v1/docs/PostApiDocs.java @@ -0,0 +1,83 @@ +package com.chalkak.backend.post.api.v1.docs; + +import com.chalkak.backend.exception.ErrorResponse; +import com.chalkak.backend.post.api.v1.dto.request.PostListRequest; +import com.chalkak.backend.post.api.v1.dto.response.PostDetailResponse; +import com.chalkak.backend.post.api.v1.dto.response.PostListResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Posts", description = "게시물 API") +public interface PostApiDocs { + + @Operation( + summary = "게시물 목록 조회", + description = "랜덤 정렬의 다음 페이지 요청에는 최초 응답의 randomSeed를 사용합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시물 목록 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 조회 조건", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "해당 날짜의 주제를 찾을 수 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity getPosts( + @ParameterObject PostListRequest request + ); + + @Operation(summary = "게시물 상세 조회") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "게시물 상세 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 게시물 ID", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "게시물을 찾을 수 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity getPost( + @Parameter( + description = "게시물 ID", + example = "0198f6c1-62ba-7d30-8b12-0f733b6570d4" + ) + String postId + ); +} diff --git a/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/request/PostListRequest.java b/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/request/PostListRequest.java index 73b04eeb..f649587c 100644 --- a/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/request/PostListRequest.java +++ b/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/request/PostListRequest.java @@ -1,6 +1,7 @@ package com.chalkak.backend.post.api.v1.dto.request; import com.chalkak.backend.post.service.PostSort; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -9,21 +10,34 @@ import org.springframework.format.annotation.DateTimeFormat; public record PostListRequest( + @Schema(description = "조회할 주제 날짜", example = "2026-08-12") @NotNull(message = "조회 조건이 올바르지 않습니다.") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate topicDate, + @Schema( + description = "정렬 방식", + defaultValue = "recent", + allowableValues = {"recent", "random"} + ) PostSort sort, + @Schema( + description = "랜덤 정렬 결과를 유지하는 값으로 다음 페이지 요청에도 동일하게 전달", + example = "f4c3a091", + nullable = true + ) @Pattern( regexp = "[A-Za-z0-9_-]{1,64}", message = "조회 조건이 올바르지 않습니다." ) String randomSeed, + @Schema(description = "페이지 번호", defaultValue = "1", example = "1") @Min(value = 1, message = "조회 조건이 올바르지 않습니다.") Integer page, + @Schema(description = "페이지당 게시물 수", defaultValue = "20", example = "20") @Min(value = 1, message = "조회 조건이 올바르지 않습니다.") @Max(value = 100, message = "조회 조건이 올바르지 않습니다.") Integer pageSize diff --git a/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostDetailResponse.java b/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostDetailResponse.java index 1111661a..c741fe0f 100644 --- a/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostDetailResponse.java +++ b/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostDetailResponse.java @@ -1,6 +1,7 @@ package com.chalkak.backend.post.api.v1.dto.response; import com.chalkak.backend.post.service.PostDetail; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.UUID; @@ -8,6 +9,7 @@ public record PostDetailResponse( UUID id, TopicResponse topic, String originalImageUrl, + @Schema(description = "변환된 게시물 썸네일 URL", nullable = true) String thumbnailImageUrl, String signatureOriginalImageUrl, String title diff --git a/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostListResponse.java b/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostListResponse.java index ea0d9a4d..52ba9f77 100644 --- a/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostListResponse.java +++ b/backend/src/main/java/com/chalkak/backend/post/api/v1/dto/response/PostListResponse.java @@ -1,6 +1,7 @@ package com.chalkak.backend.post.api.v1.dto.response; import com.chalkak.backend.post.service.PostListResult; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.Instant; import java.util.List; import java.util.UUID; @@ -9,6 +10,10 @@ public record PostListResponse( int currentPage, int pageSize, boolean hasNext, + @Schema( + description = "랜덤 정렬 결과를 유지하는 값으로 다음 페이지 요청에도 동일하게 전달", + nullable = true + ) String randomSeed, List posts ) { @@ -28,8 +33,10 @@ public static PostListResponse fromPostListResult(PostListResult result) { public record PostResponse( UUID id, String originalImageUrl, + @Schema(description = "변환된 게시물 썸네일 URL", nullable = true) String thumbnailImageUrl, String signatureOriginalImageUrl, + @Schema(description = "변환된 사인 이미지 썸네일 URL", nullable = true) String signatureThumbnailImageUrl, String title, Instant submittedAt diff --git a/backend/src/main/java/com/chalkak/backend/topic/api/v1/controller/TopicController.java b/backend/src/main/java/com/chalkak/backend/topic/api/v1/controller/TopicController.java index 97aa1305..5dd6aca1 100644 --- a/backend/src/main/java/com/chalkak/backend/topic/api/v1/controller/TopicController.java +++ b/backend/src/main/java/com/chalkak/backend/topic/api/v1/controller/TopicController.java @@ -1,5 +1,6 @@ package com.chalkak.backend.topic.api.v1.controller; +import com.chalkak.backend.topic.api.v1.docs.TopicApiDocs; import com.chalkak.backend.topic.api.v1.dto.response.TopicDetailResponse; import com.chalkak.backend.topic.service.TopicDetail; import com.chalkak.backend.topic.service.TopicService; @@ -15,10 +16,11 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/topics") -public class TopicController { +public class TopicController implements TopicApiDocs { private final TopicService topicService; + @Override @GetMapping public ResponseEntity getTopic( @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date diff --git a/backend/src/main/java/com/chalkak/backend/topic/api/v1/docs/TopicApiDocs.java b/backend/src/main/java/com/chalkak/backend/topic/api/v1/docs/TopicApiDocs.java new file mode 100644 index 00000000..4d94397f --- /dev/null +++ b/backend/src/main/java/com/chalkak/backend/topic/api/v1/docs/TopicApiDocs.java @@ -0,0 +1,47 @@ +package com.chalkak.backend.topic.api.v1.docs; + +import com.chalkak.backend.exception.ErrorResponse; +import com.chalkak.backend.topic.api.v1.dto.response.TopicDetailResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDate; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Topics", description = "주제 API") +public interface TopicApiDocs { + + @Operation(summary = "주제 조회") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "주제 조회 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 날짜 형식", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "주제를 찾을 수 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity getTopic( + @Parameter(description = "조회할 주제 날짜", example = "2026-08-12") + LocalDate date + ); +} diff --git a/backend/src/main/java/com/chalkak/backend/user/api/internal/v1/controller/SignatureProcessingCallbackController.java b/backend/src/main/java/com/chalkak/backend/user/api/internal/v1/controller/SignatureProcessingCallbackController.java index 572ec0e7..032ec138 100644 --- a/backend/src/main/java/com/chalkak/backend/user/api/internal/v1/controller/SignatureProcessingCallbackController.java +++ b/backend/src/main/java/com/chalkak/backend/user/api/internal/v1/controller/SignatureProcessingCallbackController.java @@ -1,5 +1,6 @@ package com.chalkak.backend.user.api.internal.v1.controller; +import com.chalkak.backend.user.api.internal.v1.docs.SignatureProcessingCallbackApiDocs; import com.chalkak.backend.user.infrastructure.infra.SignatureProcessingCallbackAuthenticator; import com.chalkak.backend.user.service.UserService; import java.util.UUID; @@ -18,7 +19,8 @@ * 인증 헤더를 {@code required = false}로 받는 이유는, 누락을 400이 아니라 인증 실패인 401로 다루기 위해서다. * 서명 실패는 secret 불일치나 시계 차를 뜻하는 즉시 알람 대상이므로 일반 요청 오류와 섞이면 안 된다. */ -public class SignatureProcessingCallbackController { +public class SignatureProcessingCallbackController + implements SignatureProcessingCallbackApiDocs { private static final String TIMESTAMP_HEADER = "X-Chalkak-Callback-Timestamp"; private static final String SIGNATURE_HEADER = "X-Chalkak-Callback-Signature"; @@ -26,6 +28,7 @@ public class SignatureProcessingCallbackController { private final UserService userService; private final SignatureProcessingCallbackAuthenticator authenticator; + @Override @PostMapping("/{uploadId}/complete") public ResponseEntity complete( @PathVariable UUID uploadId, @@ -38,6 +41,7 @@ public ResponseEntity complete( return ResponseEntity.noContent().build(); } + @Override @PostMapping("/{uploadId}/failed") public ResponseEntity fail( @PathVariable UUID uploadId, diff --git a/backend/src/main/java/com/chalkak/backend/user/api/internal/v1/docs/SignatureProcessingCallbackApiDocs.java b/backend/src/main/java/com/chalkak/backend/user/api/internal/v1/docs/SignatureProcessingCallbackApiDocs.java new file mode 100644 index 00000000..4f14af9b --- /dev/null +++ b/backend/src/main/java/com/chalkak/backend/user/api/internal/v1/docs/SignatureProcessingCallbackApiDocs.java @@ -0,0 +1,96 @@ +package com.chalkak.backend.user.api.internal.v1.docs; + +import com.chalkak.backend.exception.ErrorResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Internal Signature Processing", description = "사인 이미지 처리 내부 콜백 API") +public interface SignatureProcessingCallbackApiDocs { + + @Operation( + summary = "사인 이미지 처리 완료 콜백", + description = "Lambda의 사인 이미지 변환 완료 결과를 멱등하게 반영합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "완료 결과 반영 성공"), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 콜백 서명", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity complete( + @Parameter( + description = "처리할 사인 이미지 업로드 ID", + example = "0198f6c1-62ba-7d30-8b12-0f733b6570d4" + ) + UUID uploadId, + @Parameter( + name = "X-Chalkak-Callback-Timestamp", + description = "요청 시각의 Unix epoch 초. 서버 시각과 5분 이내여야 합니다.", + in = ParameterIn.HEADER, + required = true, + example = "1787562000" + ) + String timestamp, + @Parameter( + name = "X-Chalkak-Callback-Signature", + description = "timestamp, HTTP 메서드, 경로, 본문 해시를 서명한 v1 HMAC-SHA256 값", + in = ParameterIn.HEADER, + required = true, + example = "v1=0123456789abcdef" + ) + String signature + ); + + @Operation( + summary = "사인 이미지 처리 실패 콜백", + description = "Lambda의 사인 이미지 변환 실패 결과를 멱등하게 반영합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "실패 결과 반영 성공"), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 콜백 서명", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity fail( + @Parameter( + description = "처리할 사인 이미지 업로드 ID", + example = "0198f6c1-62ba-7d30-8b12-0f733b6570d4" + ) + UUID uploadId, + @Parameter( + name = "X-Chalkak-Callback-Timestamp", + description = "요청 시각의 Unix epoch 초. 서버 시각과 5분 이내여야 합니다.", + in = ParameterIn.HEADER, + required = true, + example = "1787562000" + ) + String timestamp, + @Parameter( + name = "X-Chalkak-Callback-Signature", + description = "timestamp, HTTP 메서드, 경로, 본문 해시를 서명한 v1 HMAC-SHA256 값", + in = ParameterIn.HEADER, + required = true, + example = "v1=0123456789abcdef" + ) + String signature + ); +} diff --git a/backend/src/main/java/com/chalkak/backend/user/api/v1/controller/UserController.java b/backend/src/main/java/com/chalkak/backend/user/api/v1/controller/UserController.java index fb580b33..8059aa2c 100644 --- a/backend/src/main/java/com/chalkak/backend/user/api/v1/controller/UserController.java +++ b/backend/src/main/java/com/chalkak/backend/user/api/v1/controller/UserController.java @@ -2,6 +2,7 @@ import com.chalkak.backend.auth.api.support.AuthenticatedUser; import com.chalkak.backend.auth.api.support.LoginUser; +import com.chalkak.backend.user.api.v1.docs.UserApiDocs; import com.chalkak.backend.user.api.v1.dto.request.UserSignatureUpdateRequest; import com.chalkak.backend.user.api.v1.dto.response.UserSignatureResponse; import com.chalkak.backend.user.api.v1.dto.response.UserSignatureUploadResponse; @@ -28,10 +29,11 @@ @RequiredArgsConstructor @RequestMapping("/api/v1/users") @Profile("!prod") -public class UserController { +public class UserController implements UserApiDocs { private final UserService userService; + @Override @DeleteMapping("/me") public ResponseEntity withdraw(@LoginUser AuthenticatedUser loginUser) { userService.withdraw(loginUser.userId()); @@ -39,6 +41,7 @@ public ResponseEntity withdraw(@LoginUser AuthenticatedUser loginUser) { return ResponseEntity.noContent().build(); } + @Override @PostMapping("/me/signature/uploads") public ResponseEntity createSignatureUpload( @LoginUser AuthenticatedUser loginUser @@ -48,6 +51,7 @@ public ResponseEntity createSignatureUpload( return ResponseEntity.ok(UserSignatureUploadResponse.from(upload)); } + @Override @PutMapping("/me/signature") public ResponseEntity updateSignature( @LoginUser AuthenticatedUser loginUser, diff --git a/backend/src/main/java/com/chalkak/backend/user/api/v1/docs/UserApiDocs.java b/backend/src/main/java/com/chalkak/backend/user/api/v1/docs/UserApiDocs.java new file mode 100644 index 00000000..79f9b217 --- /dev/null +++ b/backend/src/main/java/com/chalkak/backend/user/api/v1/docs/UserApiDocs.java @@ -0,0 +1,111 @@ +package com.chalkak.backend.user.api.v1.docs; + +import com.chalkak.backend.auth.api.support.AuthenticatedUser; +import com.chalkak.backend.exception.ErrorResponse; +import com.chalkak.backend.user.api.v1.dto.request.UserSignatureUpdateRequest; +import com.chalkak.backend.user.api.v1.dto.response.UserSignatureResponse; +import com.chalkak.backend.user.api.v1.dto.response.UserSignatureUploadResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Users", description = "사용자 API") +@SecurityRequirement(name = "userIdHeader") +public interface UserApiDocs { + + @Operation(summary = "회원 탈퇴") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "회원 탈퇴 성공"), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 인증 정보", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "회원을 찾을 수 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity withdraw( + @Parameter(hidden = true) AuthenticatedUser loginUser + ); + + @Operation(summary = "사인 이미지 업로드 URL 발급") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "업로드 URL 발급 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 인증 정보", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "회원을 찾을 수 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity createSignatureUpload( + @Parameter(hidden = true) AuthenticatedUser loginUser + ); + + @Operation(summary = "사인 이미지 수정") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "사인 이미지 수정 성공", + useReturnTypeSchema = true + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 이미지 업로드 정보", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "401", + description = "유효하지 않은 인증 정보", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ), + @ApiResponse( + responseCode = "404", + description = "회원 또는 업로드 이미지를 찾을 수 없음", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ErrorResponse.class) + ) + ) + }) + ResponseEntity updateSignature( + @Parameter(hidden = true) AuthenticatedUser loginUser, + UserSignatureUpdateRequest request + ); +} diff --git a/backend/src/main/java/com/chalkak/backend/user/api/v1/dto/request/UserSignatureUpdateRequest.java b/backend/src/main/java/com/chalkak/backend/user/api/v1/dto/request/UserSignatureUpdateRequest.java index 50022587..13ab497e 100644 --- a/backend/src/main/java/com/chalkak/backend/user/api/v1/dto/request/UserSignatureUpdateRequest.java +++ b/backend/src/main/java/com/chalkak/backend/user/api/v1/dto/request/UserSignatureUpdateRequest.java @@ -1,9 +1,14 @@ package com.chalkak.backend.user.api.v1.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import java.util.UUID; public record UserSignatureUpdateRequest( + @Schema( + description = "사인 원본 이미지 업로드 ID", + example = "0198f6c1-62ba-7d30-8b12-0f733b6570d4" + ) @NotNull(message = "사인 이미지 업로드 정보가 올바르지 않습니다.") UUID signatureOriginalUploadId ) { diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index d4d71571..d22640ed 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -21,3 +21,9 @@ management: chalkak: image: environment: prod + +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index decd63e1..63b67646 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -38,3 +38,10 @@ management: web: exposure: include: health,info + +springdoc: + api-docs: + enabled: true + swagger-ui: + enabled: true + urls-primary-name: user-api