Skip to content

Commit 75c7720

Browse files
authored
(#73) OpenAPI 계약 생성 기반 추가
* build: 백엔드 OpenAPI 계약 생성 기반 추가 - springdoc 기반 OpenAPI 계약과 Swagger UI 경로를 추가 - /api/v1/** 보안 스키마와 엔드포인트 설명을 명시 - openapi.json 생성 태스크와 검증 테스트를 추가 * fix: OpenAPI 계약 리뷰 피드백 반영 - 회원 탈퇴 204 응답과 서버 URL을 계약에 반영 - Swagger UI WebJar 버전을 빌드 변수로 중앙화 - OpenAPI 테스트와 스냅샷 재생성 검증을 보강
1 parent 98865ac commit 75c7720

13 files changed

Lines changed: 354 additions & 3 deletions

File tree

build.gradle

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ repositories {
1919
mavenCentral()
2020
}
2121

22+
def springdocOpenApiVersion = '2.8.16'
23+
def swaggerUiVersion = '5.32.1'
24+
2225
dependencies {
2326
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
2427
implementation 'org.springframework.boot:spring-boot-starter-validation'
@@ -27,6 +30,8 @@ dependencies {
2730
implementation 'org.springframework.boot:spring-boot-starter-web'
2831
implementation 'org.springframework.boot:spring-boot-starter-webflux'
2932
implementation 'org.springframework.boot:spring-boot-starter-aop'
33+
implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:${springdocOpenApiVersion}"
34+
implementation "org.webjars:swagger-ui:${swaggerUiVersion}"
3035
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
3136
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
3237
implementation 'org.springframework.boot:spring-boot-starter-actuator'
@@ -52,6 +57,7 @@ dependencies {
5257
testImplementation 'org.testcontainers:mysql'
5358
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.1'
5459

60+
testRuntimeOnly 'com.h2database:h2'
5561
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
5662
}
5763

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

70+
tasks.named('processResources') {
71+
filteringCharset = 'UTF-8'
72+
filesMatching('static/swagger-ui/index.html') {
73+
expand(swaggerUiVersion: swaggerUiVersion)
74+
}
75+
}
76+
6477
// 통합 테스트: *IT.java만 실행 (Docker/Testcontainers 필요)
6578
// check 라이프사이클에 포함하지 않음 — Docker 없는 환경에서 build가 실패하지 않도록 의도적 제외
6679
// CI에서는 별도 단계로 명시적 실행: ./gradlew integrationTest
@@ -103,3 +116,18 @@ tasks.named('jacocoTestCoverageVerification') {
103116
}
104117
}
105118
}
119+
120+
def trackedOpenApiSpec = layout.projectDirectory.file('docs/openapi/openapi.json')
121+
122+
tasks.register('generateOpenApiSpec', Test) {
123+
group = 'documentation'
124+
description = 'Generates the tracked OpenAPI specification snapshot.'
125+
useJUnitPlatform()
126+
include '**/OpenApiDocsTest.class'
127+
testClassesDirs = sourceSets.test.output.classesDirs
128+
classpath = sourceSets.test.runtimeClasspath
129+
systemProperty 'openapi.output', trackedOpenApiSpec.asFile.absolutePath
130+
outputs.file(trackedOpenApiSpec)
131+
132+
dependsOn tasks.named('testClasses')
133+
}

docs/openapi/README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# OpenAPI Contract
2+
3+
This directory stores the tracked OpenAPI contract for `git-ranker`.
4+
5+
## Files
6+
7+
- `openapi.json`: generated baseline contract for the public `/api/v1/**` API surface
8+
9+
## Regeneration
10+
11+
Run the following command from the repository root:
12+
13+
```bash
14+
./gradlew generateOpenApiSpec
15+
```
16+
17+
The task runs the OpenAPI test slice with the `openapi` profile and writes the latest contract to `docs/openapi/openapi.json`.
18+
19+
## Runtime Endpoints
20+
21+
- OpenAPI JSON: `/v3/api-docs`
22+
- Swagger UI: `/swagger-ui/index.html`
23+
24+
## Auth Notes
25+
26+
- Protected endpoints accept either `Authorization: Bearer <JWT>` or the `accessToken` cookie.
27+
- `/api/v1/auth/refresh` uses the `refreshToken` cookie.
28+
- The initial GitHub OAuth2 login flow is handled by Spring Security outside `/api/v1/**`.

docs/openapi/openapi.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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"}}}}

src/main/java/com/gitranker/api/domain/auth/AuthController.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
import com.gitranker.api.global.error.ErrorType;
77
import com.gitranker.api.global.response.ApiResponse;
88
import com.gitranker.api.global.util.CookieUtils;
9+
import io.swagger.v3.oas.annotations.Operation;
10+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
912
import jakarta.servlet.http.HttpServletRequest;
1013
import jakarta.servlet.http.HttpServletResponse;
1114
import lombok.RequiredArgsConstructor;
@@ -20,13 +23,22 @@
2023

2124
@Slf4j
2225
@RestController
26+
@Tag(name = "Auth")
2327
@RequestMapping("/api/v1/auth")
2428
@RequiredArgsConstructor
2529
public class AuthController {
2630

2731
private final AuthService authService;
2832

2933
@GetMapping("/me")
34+
@Operation(
35+
summary = "Get the current authenticated user",
36+
description = "Returns the current session user resolved from the access token.",
37+
security = {
38+
@SecurityRequirement(name = "bearerAuth"),
39+
@SecurityRequirement(name = "accessTokenCookie")
40+
}
41+
)
3042
public ResponseEntity<ApiResponse<AuthMeResponse>> me(@AuthenticationPrincipal User user) {
3143
if (user == null) {
3244
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(ErrorType.UNAUTHORIZED_ACCESS));
@@ -36,6 +48,11 @@ public ResponseEntity<ApiResponse<AuthMeResponse>> me(@AuthenticationPrincipal U
3648
}
3749

3850
@PostMapping("/refresh")
51+
@Operation(
52+
summary = "Refresh access and refresh tokens",
53+
description = "Requires the refreshToken cookie and rotates the active session tokens.",
54+
security = @SecurityRequirement(name = "refreshTokenCookie")
55+
)
3956
public ResponseEntity<ApiResponse<Void>> refreshToken(HttpServletRequest request, HttpServletResponse response) {
4057
String refreshToken = CookieUtils.extractRefreshToken(request);
4158
authService.refreshAccessToken(refreshToken, response);
@@ -44,6 +61,14 @@ public ResponseEntity<ApiResponse<Void>> refreshToken(HttpServletRequest request
4461
}
4562

4663
@PostMapping("/logout")
64+
@Operation(
65+
summary = "Log out the current session",
66+
description = "Requires an authenticated session and the refreshToken cookie to invalidate the current login.",
67+
security = {
68+
@SecurityRequirement(name = "bearerAuth"),
69+
@SecurityRequirement(name = "accessTokenCookie")
70+
}
71+
)
4772
public ResponseEntity<ApiResponse<Void>> logout(
4873
@AuthenticationPrincipal User user,
4974
HttpServletRequest request,
@@ -60,6 +85,14 @@ public ResponseEntity<ApiResponse<Void>> logout(
6085
}
6186

6287
@PostMapping("/logout/all")
88+
@Operation(
89+
summary = "Log out every session for the current user",
90+
description = "Revokes all refresh tokens for the authenticated user.",
91+
security = {
92+
@SecurityRequirement(name = "bearerAuth"),
93+
@SecurityRequirement(name = "accessTokenCookie")
94+
}
95+
)
6396
public ResponseEntity<ApiResponse<Void>> logoutAll(
6497
@AuthenticationPrincipal User user,
6598
HttpServletRequest request,

src/main/java/com/gitranker/api/domain/badge/BadgeController.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.gitranker.api.domain.badge;
22

33
import com.gitranker.api.domain.user.Tier;
4+
import io.swagger.v3.oas.annotations.Operation;
5+
import io.swagger.v3.oas.annotations.tags.Tag;
46
import lombok.RequiredArgsConstructor;
57
import org.springframework.http.CacheControl;
68
import org.springframework.http.MediaType;
@@ -14,12 +16,14 @@
1416

1517
@RequiredArgsConstructor
1618
@RestController
19+
@Tag(name = "Badges")
1720
@RequestMapping("/api/v1/badges")
1821
public class BadgeController {
1922

2023
private final BadgeService badgeService;
2124

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

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

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

4146
return ResponseEntity.ok()
4247
.contentType(MediaType.valueOf("image/svg+xml"))
4348
.body(svgContent);
4449
}
45-
}
50+
}

src/main/java/com/gitranker/api/domain/ranking/RankingController.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import com.gitranker.api.domain.ranking.dto.RankingList;
44
import com.gitranker.api.domain.user.Tier;
55
import com.gitranker.api.global.response.ApiResponse;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
68
import jakarta.validation.constraints.Min;
79
import lombok.RequiredArgsConstructor;
810
import org.springframework.validation.annotation.Validated;
@@ -14,12 +16,14 @@
1416
@Validated
1517
@RequiredArgsConstructor
1618
@RestController
19+
@Tag(name = "Ranking")
1720
@RequestMapping("/api/v1/ranking")
1821
public class RankingController {
1922

2023
private final RankingService rankingService;
2124

2225
@GetMapping
26+
@Operation(summary = "List ranking entries", description = "Returns paginated ranking results with an optional tier filter.")
2327
public ApiResponse<RankingList> getRankings(
2428
@RequestParam(defaultValue = "0") @Min(value = 0, message = "{validation.ranking.page.min}") int page,
2529
@RequestParam(required = false) Tier tier

src/main/java/com/gitranker/api/domain/user/UserController.java

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import com.gitranker.api.global.error.ErrorType;
88
import com.gitranker.api.global.error.exception.BusinessException;
99
import com.gitranker.api.global.response.ApiResponse;
10+
import io.swagger.v3.oas.annotations.Operation;
11+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
12+
import io.swagger.v3.oas.annotations.tags.Tag;
1013
import jakarta.servlet.http.HttpServletResponse;
1114
import jakarta.validation.constraints.Pattern;
1215
import lombok.RequiredArgsConstructor;
@@ -18,6 +21,7 @@
1821
@Validated
1922
@RequiredArgsConstructor
2023
@RestController
24+
@Tag(name = "Users")
2125
@RequestMapping("/api/v1/users")
2226
public class UserController {
2327

@@ -28,6 +32,7 @@ public class UserController {
2832
private final UserDeletionService userDeletionService;
2933

3034
@GetMapping("/{username}")
35+
@Operation(summary = "Get a user's profile", description = "Returns the public Git Ranker profile for a GitHub username.")
3136
public ApiResponse<RegisterUserResponse> getUser(
3237
@PathVariable @Pattern(regexp = USERNAME_PATTERN, message = USERNAME_MESSAGE) String username
3338
) {
@@ -37,6 +42,14 @@ public ApiResponse<RegisterUserResponse> getUser(
3742
}
3843

3944
@PostMapping("/{username}/refresh")
45+
@Operation(
46+
summary = "Refresh the authenticated user's score",
47+
description = "Recalculates the caller's own profile. The authenticated user must match the path username.",
48+
security = {
49+
@SecurityRequirement(name = "bearerAuth"),
50+
@SecurityRequirement(name = "accessTokenCookie")
51+
}
52+
)
4053
public ApiResponse<RegisterUserResponse> refreshUser(
4154
@PathVariable @Pattern(regexp = USERNAME_PATTERN, message = USERNAME_MESSAGE) String username,
4255
@AuthenticationPrincipal User user
@@ -55,6 +68,15 @@ public ApiResponse<RegisterUserResponse> refreshUser(
5568
}
5669

5770
@DeleteMapping("/me")
71+
@Operation(
72+
summary = "Delete the authenticated user's account",
73+
description = "Deletes the current account and clears authentication cookies.",
74+
security = {
75+
@SecurityRequirement(name = "bearerAuth"),
76+
@SecurityRequirement(name = "accessTokenCookie")
77+
}
78+
)
79+
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "No Content")
5880
public ResponseEntity<Void> deleteMyAccount(
5981
@AuthenticationPrincipal User user,
6082
HttpServletResponse response
@@ -68,4 +90,3 @@ public ResponseEntity<Void> deleteMyAccount(
6890
return ResponseEntity.noContent().build();
6991
}
7092
}
71-

0 commit comments

Comments
 (0)