-
Notifications
You must be signed in to change notification settings - Fork 1
build: OpenAPI 계약 생성 기반 추가 #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| # OpenAPI Contract | ||
|
|
||
| This directory stores the tracked OpenAPI contract for `git-ranker`. | ||
|
|
||
| ## Files | ||
|
|
||
| - `openapi.json`: generated baseline contract for the public `/api/v1/**` API surface | ||
|
|
||
| ## Regeneration | ||
|
|
||
| Run the following command from the repository root: | ||
|
|
||
| ```bash | ||
| ./gradlew generateOpenApiSpec | ||
| ``` | ||
|
|
||
| The task runs the OpenAPI test slice with the `openapi` profile and writes the latest contract to `docs/openapi/openapi.json`. | ||
|
|
||
| ## Runtime Endpoints | ||
|
|
||
| - OpenAPI JSON: `/v3/api-docs` | ||
| - Swagger UI: `/swagger-ui/index.html` | ||
|
|
||
| ## Auth Notes | ||
|
|
||
| - Protected endpoints accept either `Authorization: Bearer <JWT>` or the `accessToken` cookie. | ||
| - `/api/v1/auth/refresh` uses the `refreshToken` cookie. | ||
| - The initial GitHub OAuth2 login flow is handled by Spring Security outside `/api/v1/**`. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"openapi":"3.1.0","info":{"title":"Git Ranker API","description":"Machine-readable contract for Git Ranker's public `/api/v1/**` endpoints.\n\nAuthentication model:\n- Protected endpoints accept either an `Authorization: Bearer <JWT>` header or the `accessToken` cookie.\n- `/api/v1/auth/refresh` uses the `refreshToken` cookie.\n- Initial sign-in starts with the GitHub OAuth2 redirect flow exposed by Spring Security outside `/api/v1/**`.\n","version":"v1"},"servers":[{"url":"https://www.git-ranker.com","description":"Production"},{"url":"http://localhost:8080","description":"Local development"}],"paths":{"/api/v1/users/{username}/refresh":{"post":{"tags":["Users"],"summary":"Refresh the authenticated user's score","description":"Recalculates the caller's own profile. The authenticated user must match the path username.","operationId":"refreshUser","parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string","pattern":"^(?=.{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseRegisterUserResponse"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/auth/refresh":{"post":{"tags":["Auth"],"summary":"Refresh access and refresh tokens","description":"Requires the refreshToken cookie and rotates the active session tokens.","operationId":"refreshToken","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseVoid"}}}}},"security":[{"refreshTokenCookie":[]}]}},"/api/v1/auth/logout":{"post":{"tags":["Auth"],"summary":"Log out the current session","description":"Requires an authenticated session and the refreshToken cookie to invalidate the current login.","operationId":"logout","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseVoid"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/auth/logout/all":{"post":{"tags":["Auth"],"summary":"Log out every session for the current user","description":"Revokes all refresh tokens for the authenticated user.","operationId":"logoutAll","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseVoid"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/users/{username}":{"get":{"tags":["Users"],"summary":"Get a user's profile","description":"Returns the public Git Ranker profile for a GitHub username.","operationId":"getUser","parameters":[{"name":"username","in":"path","required":true,"schema":{"type":"string","pattern":"^(?=.{1,39}$)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*$"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseRegisterUserResponse"}}}}}}},"/api/v1/ranking":{"get":{"tags":["Ranking"],"summary":"List ranking entries","description":"Returns paginated ranking results with an optional tier filter.","operationId":"getRankings","parameters":[{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0,"minimum":0}},{"name":"tier","in":"query","required":false,"schema":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseRankingList"}}}}}}},"/api/v1/badges/{tier}/badge":{"get":{"tags":["Badges"],"summary":"Render a tier badge","description":"Returns an SVG badge template for the requested tier.","operationId":"getBadgeByTier","parameters":[{"name":"tier","in":"path","required":true,"schema":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]}}],"responses":{"200":{"description":"OK","content":{"image/svg+xml":{"schema":{"type":"string"}}}}}}},"/api/v1/badges/{nodeId}":{"get":{"tags":["Badges"],"summary":"Render a badge for a GitHub node id","description":"Returns an SVG badge for a user's current Git Ranker profile.","operationId":"getBadge","parameters":[{"name":"nodeId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"image/svg+xml":{"schema":{"type":"string"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["Auth"],"summary":"Get the current authenticated user","description":"Returns the current session user resolved from the access token.","operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ApiResponseAuthMeResponse"}}}}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}},"/api/v1/users/me":{"delete":{"tags":["Users"],"summary":"Delete the authenticated user's account","description":"Deletes the current account and clears authentication cookies.","operationId":"deleteMyAccount","responses":{"204":{"description":"No Content"}},"security":[{"bearerAuth":[]},{"accessTokenCookie":[]}]}}},"components":{"schemas":{"ApiResponseRegisterUserResponse":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{"$ref":"#/components/schemas/RegisterUserResponse"},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"ErrorMessage":{"type":"object","properties":{"type":{"type":"string"},"message":{"type":"string"},"data":{}}},"RegisterUserResponse":{"type":"object","properties":{"userId":{"type":"integer","format":"int64"},"githubId":{"type":"integer","format":"int64"},"nodeId":{"type":"string"},"username":{"type":"string"},"email":{"type":"string"},"profileImage":{"type":"string"},"role":{"type":"string","enum":["GUEST","USER","ADMIN"]},"updatedAt":{"type":"string","format":"date-time"},"lastFullScanAt":{"type":"string","format":"date-time"},"totalScore":{"type":"integer","format":"int32"},"ranking":{"type":"integer","format":"int32"},"tier":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]},"percentile":{"type":"number","format":"double"},"commitCount":{"type":"integer","format":"int32"},"issueCount":{"type":"integer","format":"int32"},"prCount":{"type":"integer","format":"int32"},"mergedPrCount":{"type":"integer","format":"int32"},"reviewCount":{"type":"integer","format":"int32"},"diffCommitCount":{"type":"integer","format":"int32"},"diffIssueCount":{"type":"integer","format":"int32"},"diffPrCount":{"type":"integer","format":"int32"},"diffMergedPrCount":{"type":"integer","format":"int32"},"diffReviewCount":{"type":"integer","format":"int32"},"isNewUser":{"type":"boolean"}}},"ApiResponseVoid":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"ApiResponseRankingList":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{"$ref":"#/components/schemas/RankingList"},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"PageInfo":{"type":"object","properties":{"currentPage":{"type":"integer","format":"int32"},"pageSize":{"type":"integer","format":"int32"},"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"isFirst":{"type":"boolean"},"isLast":{"type":"boolean"}}},"RankingList":{"type":"object","properties":{"rankings":{"type":"array","items":{"$ref":"#/components/schemas/UserInfo"}},"pageInfo":{"$ref":"#/components/schemas/PageInfo"}}},"UserInfo":{"type":"object","properties":{"username":{"type":"string"},"profileImage":{"type":"string"},"ranking":{"type":"integer","format":"int64"},"totalScore":{"type":"integer","format":"int32"},"tier":{"type":"string","enum":["CHALLENGER","MASTER","DIAMOND","EMERALD","PLATINUM","GOLD","SILVER","BRONZE","IRON"]}}},"ApiResponseAuthMeResponse":{"type":"object","properties":{"result":{"type":"string","enum":["SUCCESS","ERROR"]},"data":{"$ref":"#/components/schemas/AuthMeResponse"},"error":{"$ref":"#/components/schemas/ErrorMessage"}}},"AuthMeResponse":{"type":"object","properties":{"username":{"type":"string"},"profileImage":{"type":"string"},"role":{"type":"string","enum":["GUEST","USER","ADMIN"]}}}},"securitySchemes":{"bearerAuth":{"type":"http","description":"Send `Authorization: Bearer <JWT>` for protected API calls.","scheme":"bearer","bearerFormat":"JWT"},"accessTokenCookie":{"type":"apiKey","description":"Browser session alternative to bearerAuth.","name":"accessToken","in":"cookie"},"refreshTokenCookie":{"type":"apiKey","description":"Required by refresh and logout flows that rotate or revoke session tokens.","name":"refreshToken","in":"cookie"}}}} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,9 @@ | |
| import com.gitranker.api.global.error.ErrorType; | ||
| import com.gitranker.api.global.response.ApiResponse; | ||
| import com.gitranker.api.global.util.CookieUtils; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
@@ -20,13 +23,22 @@ | |
|
|
||
| @Slf4j | ||
| @RestController | ||
| @Tag(name = "Auth") | ||
| @RequestMapping("/api/v1/auth") | ||
| @RequiredArgsConstructor | ||
| public class AuthController { | ||
|
|
||
| private final AuthService authService; | ||
|
|
||
| @GetMapping("/me") | ||
| @Operation( | ||
| summary = "Get the current authenticated user", | ||
| description = "Returns the current session user resolved from the access token.", | ||
| security = { | ||
| @SecurityRequirement(name = "bearerAuth"), | ||
| @SecurityRequirement(name = "accessTokenCookie") | ||
| } | ||
| ) | ||
| public ResponseEntity<ApiResponse<AuthMeResponse>> me(@AuthenticationPrincipal User user) { | ||
| if (user == null) { | ||
| return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(ErrorType.UNAUTHORIZED_ACCESS)); | ||
|
|
@@ -36,6 +48,11 @@ public ResponseEntity<ApiResponse<AuthMeResponse>> me(@AuthenticationPrincipal U | |
| } | ||
|
|
||
| @PostMapping("/refresh") | ||
| @Operation( | ||
| summary = "Refresh access and refresh tokens", | ||
| description = "Requires the refreshToken cookie and rotates the active session tokens.", | ||
| security = @SecurityRequirement(name = "refreshTokenCookie") | ||
| ) | ||
| public ResponseEntity<ApiResponse<Void>> refreshToken(HttpServletRequest request, HttpServletResponse response) { | ||
| String refreshToken = CookieUtils.extractRefreshToken(request); | ||
| authService.refreshAccessToken(refreshToken, response); | ||
|
|
@@ -44,6 +61,14 @@ public ResponseEntity<ApiResponse<Void>> refreshToken(HttpServletRequest request | |
| } | ||
|
|
||
| @PostMapping("/logout") | ||
| @Operation( | ||
| summary = "Log out the current session", | ||
| description = "Requires an authenticated session and the refreshToken cookie to invalidate the current login.", | ||
| security = { | ||
| @SecurityRequirement(name = "bearerAuth"), | ||
| @SecurityRequirement(name = "accessTokenCookie") | ||
| } | ||
|
Comment on lines
+67
to
+70
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
| ) | ||
| public ResponseEntity<ApiResponse<Void>> logout( | ||
| @AuthenticationPrincipal User user, | ||
| HttpServletRequest request, | ||
|
|
@@ -60,6 +85,14 @@ public ResponseEntity<ApiResponse<Void>> logout( | |
| } | ||
|
|
||
| @PostMapping("/logout/all") | ||
| @Operation( | ||
| summary = "Log out every session for the current user", | ||
| description = "Revokes all refresh tokens for the authenticated user.", | ||
| security = { | ||
| @SecurityRequirement(name = "bearerAuth"), | ||
| @SecurityRequirement(name = "accessTokenCookie") | ||
| } | ||
| ) | ||
| public ResponseEntity<ApiResponse<Void>> logoutAll( | ||
| @AuthenticationPrincipal User user, | ||
| HttpServletRequest request, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,9 @@ | |
| import com.gitranker.api.global.error.ErrorType; | ||
| import com.gitranker.api.global.error.exception.BusinessException; | ||
| import com.gitranker.api.global.response.ApiResponse; | ||
| import io.swagger.v3.oas.annotations.Operation; | ||
| import io.swagger.v3.oas.annotations.security.SecurityRequirement; | ||
| import io.swagger.v3.oas.annotations.tags.Tag; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import jakarta.validation.constraints.Pattern; | ||
| import lombok.RequiredArgsConstructor; | ||
|
|
@@ -18,6 +21,7 @@ | |
| @Validated | ||
| @RequiredArgsConstructor | ||
| @RestController | ||
| @Tag(name = "Users") | ||
| @RequestMapping("/api/v1/users") | ||
| public class UserController { | ||
|
|
||
|
|
@@ -28,6 +32,7 @@ public class UserController { | |
| private final UserDeletionService userDeletionService; | ||
|
|
||
| @GetMapping("/{username}") | ||
| @Operation(summary = "Get a user's profile", description = "Returns the public Git Ranker profile for a GitHub username.") | ||
| public ApiResponse<RegisterUserResponse> getUser( | ||
| @PathVariable @Pattern(regexp = USERNAME_PATTERN, message = USERNAME_MESSAGE) String username | ||
| ) { | ||
|
|
@@ -37,6 +42,14 @@ public ApiResponse<RegisterUserResponse> getUser( | |
| } | ||
|
|
||
| @PostMapping("/{username}/refresh") | ||
| @Operation( | ||
| summary = "Refresh the authenticated user's score", | ||
| description = "Recalculates the caller's own profile. The authenticated user must match the path username.", | ||
| security = { | ||
| @SecurityRequirement(name = "bearerAuth"), | ||
| @SecurityRequirement(name = "accessTokenCookie") | ||
| } | ||
| ) | ||
| public ApiResponse<RegisterUserResponse> refreshUser( | ||
| @PathVariable @Pattern(regexp = USERNAME_PATTERN, message = USERNAME_MESSAGE) String username, | ||
| @AuthenticationPrincipal User user | ||
|
|
@@ -55,6 +68,15 @@ public ApiResponse<RegisterUserResponse> refreshUser( | |
| } | ||
|
|
||
| @DeleteMapping("/me") | ||
| @Operation( | ||
| summary = "Delete the authenticated user's account", | ||
| description = "Deletes the current account and clears authentication cookies.", | ||
| security = { | ||
|
Comment on lines
+71
to
+74
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This endpoint returns Useful? React with 👍 / 👎. |
||
| @SecurityRequirement(name = "bearerAuth"), | ||
| @SecurityRequirement(name = "accessTokenCookie") | ||
| } | ||
| ) | ||
| @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "No Content") | ||
| public ResponseEntity<Void> deleteMyAccount( | ||
| @AuthenticationPrincipal User user, | ||
| HttpServletResponse response | ||
|
|
@@ -68,4 +90,3 @@ public ResponseEntity<Void> deleteMyAccount( | |
| return ResponseEntity.noContent().build(); | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new contract marks
POST /api/v1/auth/logoutas authorized bybearerAuthoraccessTokenCookie, but the implementation always callsCookieUtils.extractRefreshToken(request)and fails withINVALID_REFRESH_TOKENwhen therefreshTokencookie is missing. This makes the published OpenAPI spec inaccurate and will cause generated clients/integration harnesses to send requests that are documented as valid but fail at runtime.Useful? React with 👍 / 👎.