Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ repositories {
mavenCentral()
}

def springdocOpenApiVersion = '2.8.16'
def swaggerUiVersion = '5.32.1'

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
Expand All @@ -27,6 +30,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:${springdocOpenApiVersion}"
implementation "org.webjars:swagger-ui:${swaggerUiVersion}"
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
Expand All @@ -52,6 +57,7 @@ dependencies {
testImplementation 'org.testcontainers:mysql'
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1'

testRuntimeOnly 'com.h2database:h2'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Expand All @@ -61,6 +67,13 @@ tasks.named('test') {
exclude '**/*IT.class'
}

tasks.named('processResources') {
filteringCharset = 'UTF-8'
filesMatching('static/swagger-ui/index.html') {
expand(swaggerUiVersion: swaggerUiVersion)
}
}

// 통합 테스트: *IT.java만 실행 (Docker/Testcontainers 필요)
// check 라이프사이클에 포함하지 않음 — Docker 없는 환경에서 build가 실패하지 않도록 의도적 제외
// CI에서는 별도 단계로 명시적 실행: ./gradlew integrationTest
Expand Down Expand Up @@ -103,3 +116,18 @@ tasks.named('jacocoTestCoverageVerification') {
}
}
}

def trackedOpenApiSpec = layout.projectDirectory.file('docs/openapi/openapi.json')

tasks.register('generateOpenApiSpec', Test) {
group = 'documentation'
description = 'Generates the tracked OpenAPI specification snapshot.'
useJUnitPlatform()
include '**/OpenApiDocsTest.class'
testClassesDirs = sourceSets.test.output.classesDirs
classpath = sourceSets.test.runtimeClasspath
systemProperty 'openapi.output', trackedOpenApiSpec.asFile.absolutePath
outputs.file(trackedOpenApiSpec)

dependsOn tasks.named('testClasses')
}
28 changes: 28 additions & 0 deletions docs/openapi/README.md
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/**`.
1 change: 1 addition & 0 deletions docs/openapi/openapi.json
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"}}}}
33 changes: 33 additions & 0 deletions src/main/java/com/gitranker/api/domain/auth/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand All @@ -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);
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require refresh token cookie in logout OpenAPI security

The new contract marks POST /api/v1/auth/logout as authorized by bearerAuth or accessTokenCookie, but the implementation always calls CookieUtils.extractRefreshToken(request) and fails with INVALID_REFRESH_TOKEN when the refreshToken cookie 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 👍 / 👎.

Comment on lines +67 to +70
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add refresh cookie to logout OpenAPI security requirements

/api/v1/auth/logout is documented with bearerAuth/accessTokenCookie only, but the controller always calls CookieUtils.extractRefreshToken(request) inside logout(...), so requests without a refreshToken cookie fail with INVALID_REFRESH_TOKEN. This makes the generated contract inaccurate and can break clients/harnesses that rely on OpenAPI to build valid logout requests; the operation should declare refreshTokenCookie as required alongside the authentication scheme.

Useful? React with 👍 / 👎.

)
public ResponseEntity<ApiResponse<Void>> logout(
@AuthenticationPrincipal User user,
HttpServletRequest request,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.gitranker.api.domain.badge;

import com.gitranker.api.domain.user.Tier;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
Expand All @@ -14,12 +16,14 @@

@RequiredArgsConstructor
@RestController
@Tag(name = "Badges")
@RequestMapping("/api/v1/badges")
public class BadgeController {

private final BadgeService badgeService;

@GetMapping(value = "/{nodeId}", produces = "image/svg+xml")
@Operation(summary = "Render a badge for a GitHub node id", description = "Returns an SVG badge for a user's current Git Ranker profile.")
public ResponseEntity<String> getBadge(@PathVariable String nodeId) {
String svgContent = badgeService.generateBadge(nodeId);

Expand All @@ -35,11 +39,12 @@ public ResponseEntity<String> getBadge(@PathVariable String nodeId) {
}

@GetMapping(value = "/{tier}/badge", produces = "image/svg+xml")
@Operation(summary = "Render a tier badge", description = "Returns an SVG badge template for the requested tier.")
public ResponseEntity<String> getBadgeByTier(@PathVariable Tier tier) {
String svgContent = badgeService.generateBadgeByTier(tier);

return ResponseEntity.ok()
.contentType(MediaType.valueOf("image/svg+xml"))
.body(svgContent);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import com.gitranker.api.domain.ranking.dto.RankingList;
import com.gitranker.api.domain.user.Tier;
import com.gitranker.api.global.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.Min;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
Expand All @@ -14,12 +16,14 @@
@Validated
@RequiredArgsConstructor
@RestController
@Tag(name = "Ranking")
@RequestMapping("/api/v1/ranking")
public class RankingController {

private final RankingService rankingService;

@GetMapping
@Operation(summary = "List ranking entries", description = "Returns paginated ranking results with an optional tier filter.")
public ApiResponse<RankingList> getRankings(
@RequestParam(defaultValue = "0") @Min(value = 0, message = "{validation.ranking.page.min}") int page,
@RequestParam(required = false) Tier tier
Expand Down
23 changes: 22 additions & 1 deletion src/main/java/com/gitranker/api/domain/user/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +21,7 @@
@Validated
@RequiredArgsConstructor
@RestController
@Tag(name = "Users")
@RequestMapping("/api/v1/users")
public class UserController {

Expand All @@ -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
) {
Expand All @@ -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
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document delete account endpoint as 204, not default 200

This endpoint returns ResponseEntity.noContent() (HTTP 204), but the added OpenAPI metadata does not declare a 204 response, so the generated snapshot currently advertises only 200 for DELETE /api/v1/users/me. Because this commit’s goal is machine-readable contract generation, the mismatch can break generated clients that validate expected status codes against the spec.

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
Expand All @@ -68,4 +90,3 @@ public ResponseEntity<Void> deleteMyAccount(
return ResponseEntity.noContent().build();
}
}

Loading
Loading