Skip to content

build: OpenAPI 계약 생성 기반 추가#73

Merged
alexization merged 2 commits intodevelopfrom
feat/grb-01-openapi-contract
Mar 24, 2026
Merged

build: OpenAPI 계약 생성 기반 추가#73
alexization merged 2 commits intodevelopfrom
feat/grb-01-openapi-contract

Conversation

@alexization
Copy link
Copy Markdown
Owner

@alexization alexization commented Mar 24, 2026

1) 요약

  • Spring 기반 OpenAPI 계약 생성과 Swagger UI 노출 경로를 추가했습니다.
  • frontend/workflow가 기준 계약을 자동 소비할 수 있도록 docs/openapi/openapi.json과 재생성 태스크를 고정해야 해서 지금 필요했습니다.

2) 연관 이슈

3) 문제와 목표

  • 문제: backend에 기계가 읽을 수 있는 API 계약이 없어 타입 드리프트와 후속 하네스 작업 기준선이 흔들렸습니다.
  • 사용자/운영자 관점의 결과: /v3/api-docs, /swagger-ui/index.html, docs/openapi/openapi.json을 기준으로 공개 /api/v1/** 계약을 재생성하고 검토할 수 있습니다.
  • 비목표: frontend generated client 생성, API 비즈니스 로직 변경

4) 영향 범위

  • 변경된 패키지/모듈:
    • global/config
    • domain/auth
    • domain/user
    • domain/ranking
    • domain/badge
    • src/test/java/com/gitranker/api/docs
    • docs/openapi
  • API/DTO/Schema 영향:
    • /api/v1/** OpenAPI 계약과 보안 스키마가 새로 생성됩니다.
    • 런타임 노출 경로로 /v3/api-docs, /swagger-ui/index.html이 추가됩니다.
    • 리뷰 반영으로 DELETE /api/v1/users/me가 실제 구현과 동일한 204 응답으로 문서화되고, 계약에 production/local 서버 URL이 명시됩니다.
  • DB/Cache/Batch/Scheduler 영향:
    • 없음
  • 보안/권한 영향:
    • docs 경로(/v3/api-docs, /swagger-ui/**, /webjars/**)를 permitAll로 추가했습니다.
    • 보호 엔드포인트 인증 방식은 bearer/cookie 스키마로 문서화했습니다.

5) 검증 증거

유형 명령어 / 증거 결과
Build ./gradlew build -x test 성공
Unit ./gradlew test 성공
Integration ./gradlew integrationTest 실패(Testcontainers Docker provider 단계에서 ContainerFetchException)
Coverage ./gradlew test jacocoTestCoverageVerification 성공
API/Manual Smoke ./gradlew test --tests "com.gitranker.api.docs.OpenApiDocsTest"
./gradlew generateOpenApiSpec
jq '{servers: .servers, deleteMyAccountResponses: .paths["/api/v1/users/me"].delete.responses}' docs/openapi/openapi.json
성공(/v3/api-docs, /swagger-ui/index.html, docs/openapi/openapi.json, 204 응답, 서버 URL 확인)

6) 관측성 확인

  • 확인한 로그: OpenAPI test/profile 기동 및 shutdown 로그만 확인했습니다. 기능 로직/배치/외부 연동 로그 변화는 없습니다.
  • 확인한 메트릭: 해당 없음. 계약/문서 노출 작업으로 메트릭 의미 변화가 없습니다.
  • 확인한 trace/dashboard/query: 해당 없음. runtime behavior 변경이 아니라서 별도 trace/dashboard 확인은 수행하지 않았습니다.

7) AI 리뷰 메모 (선택)

  • Codex: PR build: OpenAPI 계약 생성 기반 추가 #73 리뷰 코멘트를 기준으로 계약 응답 코드, 서버 URL, Swagger UI 버전 관리 방식을 재점검하고 반영했습니다.
  • CodeRabbitAI: 제안 4건 중 3건(204 응답, 서버 목록, Swagger UI 버전 중앙화)을 반영했고, OpenApiDocsTest의 parent null 가드는 현재 커밋에서 함께 반영했습니다.

8) 리스크 및 롤백

  • 리스크:
    • 운영에서 API 문서 경로가 추가로 노출됩니다.
    • springdoc starter 대신 API starter + 정적 Swagger UI 조합을 사용하므로 UI 자산 버전 업데이트 시 점검이 필요합니다.
    • integration test는 여전히 Docker/Testcontainers provider 이슈가 남아 있습니다.
  • 롤백 계획:
    • build.gradle, SecurityConfig, OpenApiConfig, docs/openapi/, Swagger UI 정적 파일을 되돌리면 됩니다.

9) 체크리스트

  • 연관 이슈가 연결되어 있음
  • Build / Unit / Integration 결과가 기입되어 있음
  • API/스키마/배치 영향이 반영되었거나 없음을 명시함
  • 로그/메트릭/trace 확인 내용을 적었거나 불필요 사유를 적음
  • 문서 또는 후속 이슈가 업데이트되었거나 불필요 사유를 적음

- springdoc 기반 OpenAPI 계약과 Swagger UI 경로를 추가
- /api/v1/** 보안 스키마와 엔드포인트 설명을 명시
- openapi.json 생성 태스크와 검증 테스트를 추가
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

이 PR은 springdoc-openapi 의존성을 추가하고 OpenAPI 구성 및 Swagger 어노테이션을 도입하여 /api/v1/** API에 대한 기계 읽기 가능 계약을 생성합니다. 추적되는 openapi.json 파일, 재생성 작업, Swagger UI 페이지 및 테스트를 포함합니다.

Changes

Cohort / File(s) Summary
Build & Task Configuration
build.gradle
springdoc-openapi 및 swagger-ui 런타임 의존성 추가, OpenAPI 스냅샷 생성을 위한 generateOpenApiSpec Gradle 작업(Test 타입) 등록, 문서화 그룹 하에 배치
OpenAPI 문서화
docs/openapi/README.md, docs/openapi/openapi.json
OpenAPI 계약 재생성 명령어, 런타임 엔드포인트(/v3/api-docs, /swagger-ui/index.html), 인증 방식(Bearer JWT, 쿠키) 설명 추가; OpenAPI 3.1 사양으로 공개 API 엔드포인트, 경로/쿼리 매개변수, 응답 스키마 및 보안 스킴 정의
Controller Swagger 어노테이션
src/main/java/com/gitranker/api/domain/auth/AuthController.java, src/main/java/com/gitranker/api/domain/user/UserController.java, src/main/java/com/gitranker/api/domain/badge/BadgeController.java, src/main/java/com/gitranker/api/domain/ranking/RankingController.java
각 컨트롤러에 @Tag 클래스 레벨 어노테이션 추가, 엔드포인트에 @Operation 메타데이터(요약, 설명, 보안 요구사항) 추가; 제어 흐름 및 로직 변경 없음
OpenAPI 구성 및 보안
src/main/java/com/gitranker/api/global/config/OpenApiConfig.java, src/main/java/com/gitranker/api/global/config/SecurityConfig.java
OpenAPI 빈 정의(Info, 보안 스킴: bearerAuth, accessTokenCookie, refreshTokenCookie); OpenAPI/Swagger 엔드포인트(/v3/api-docs/**, /swagger-ui/**, /webjars/**) 접근 허용
설정 파일
src/main/resources/application.yml, src/test/resources/application-openapi.yml
Springdoc 경로 필터(/api/v1/**), Swagger UI 정렬 설정(alpha); openapi 프로필에 H2 datasource, JPA, OAuth2, JWT, GitHub API 설정 추가
OpenAPI 테스트 및 UI
src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java, src/main/resources/static/swagger-ui/index.html
OpenAPI JSON 엔드포인트 검증(경로, 보안 스킴, 액추에이터 제외), 조건부 파일 출력; Swagger UI 정적 페이지(WebJars 기반, /v3/api-docs에서 사양 로드)

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

enhancement

Poem

🐰 계약서 생성되니 기쁨 넘쳐,
API 문서 자동 그려져,
Swagger UI 반짝 빛나고,
드리프트 걱정 이제 날아가고,
기계도 인간도 행복하게! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed 코드 변경사항이 #72의 모든 핵심 요구사항을 충족합니다: 한 명령(./gradlew generateOpenApiSpec)으로 OpenAPI JSON 재생성 가능, /api/v1/** 전체 계약 포함, 저장소 기준 파일(docs/openapi/openapi.json) 제공, /v3/api-docs와 Swagger UI 노출.
Out of Scope Changes check ✅ Passed 모든 변경사항이 OpenAPI 계약 생성 및 문서화 범위 내에 있으며, 프런트엔드 클라이언트 생성이나 API 비즈니스 로직 변경은 포함되지 않았습니다.
Title check ✅ Passed The PR title 'build: OpenAPI 계약 생성 기반 추가' accurately and concisely describes the main change: adding OpenAPI contract generation infrastructure to the build system, which aligns with all the changes made (Gradle task, annotations, configuration, documentation).
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/grb-01-openapi-contract

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@alexization alexization self-assigned this Mar 24, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9dbe512ec0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +67 to +70
security = {
@SecurityRequirement(name = "bearerAuth"),
@SecurityRequirement(name = "accessTokenCookie")
}
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 +71 to +74
@Operation(
summary = "Delete the authenticated user's account",
description = "Deletes the current account and clears authentication cookies.",
security = {
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 👍 / 👎.

@alexization alexization changed the title build: 백엔드 OpenAPI 계약 생성 기반 추가 build: OpenAPI 계약 생성 기반 추가 Mar 24, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (1)
src/main/resources/static/swagger-ui/index.html (1)

7-13: 버전 하드코딩으로 인한 유지보수 부담

WebJars 버전(5.29.4)이 HTML과 build.gradle에 중복 선언되어 있습니다. 현재는 동기화되어 있지만, 향후 업그레이드 시 불일치 위험이 있습니다.

springdoc의 기본 Swagger UI 페이지(/swagger-ui/index.html 자동 제공)를 사용하거나, 버전을 서버 사이드에서 주입하는 방식을 고려해 보세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/resources/static/swagger-ui/index.html` around lines 7 - 13, 현재
index.html에 하드코딩된 WebJars 버전 문자열 "5.29.4"이 swagger-ui.css, swagger-ui-bundle.js,
swagger-ui-standalone-preset.js 링크에 중복 선언되어 있어 유지보수 문제가 발생합니다; 해당 파일들에서 하드코딩된
"5.29.4"를 제거하고 대신 서버가 제공하는 기본 Swagger UI 페이지를 사용하거나(예: springdoc이 제공하는
/swagger-ui/index.html로 대체) 또는 템플릿 변수(예: ${swaggerUiVersion})로 버전을 주입해
build.gradle의 버전과 중앙 관리하도록 변경하세요; 대상 식별자로는 링크/스크립트 태그 내 "swagger-ui.css",
"swagger-ui-bundle.js", "swagger-ui-standalone-preset.js"와 빌드 파일인 build.gradle을
참고해 수정하십시오.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@build.gradle`:
- Around line 30-31: Update the swagger-ui dependency version in build.gradle:
replace the existing implementation 'org.webjars:swagger-ui:5.29.4' with the
newer release 'org.webjars:swagger-ui:5.32.1' so the dependency coordinates
(org.webjars:swagger-ui) point to v5.32.1; keep the
springdoc-openapi-starter-webmvc-api dependency unchanged.

In `@docs/openapi/openapi.json`:
- Line 1: The OpenAPI spec documents deleteMyAccount as returning 200 but
UserController.deleteMyAccount() actually returns HTTP 204; update the
controller and/or spec so they match: add
`@io.swagger.v3.oas.annotations.responses.ApiResponse`(responseCode = "204",
description = "No Content") to the UserController.deleteMyAccount(...) method
(alongside `@Operation`) and regenerate/update the OpenAPI definition so the
/api/v1/users/me DELETE operation uses response code 204 instead of 200.
- Line 1: The OpenAPI spec currently contains a default http://localhost server
because OpenApiConfig.gitRankerOpenApi() doesn't set servers; update
gitRankerOpenApi() to call the OpenAPI builder's .servers(...) and add Server
entries for production and development (or inject an environment property) so
the produced spec includes the correct HTTPS production URL (use Server class
and .url/.description in the builder chain).

In `@src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java`:
- Around line 69-78: persistIfRequested currently calls
Files.createDirectories(outputFile.getParent()) but outputFile.getParent() can
be null when OPENAPI_OUTPUT_PROPERTY provides a filename with no parent (e.g.,
"openapi.json"), causing a NullPointerException; modify persistIfRequested to
check whether outputFile.getParent() is non-null before calling
Files.createDirectories (i.e., only create directories when
outputFile.getParent() != null), then proceed to Files.writeString(outputFile,
responseBody).

---

Nitpick comments:
In `@src/main/resources/static/swagger-ui/index.html`:
- Around line 7-13: 현재 index.html에 하드코딩된 WebJars 버전 문자열 "5.29.4"이
swagger-ui.css, swagger-ui-bundle.js, swagger-ui-standalone-preset.js 링크에 중복
선언되어 있어 유지보수 문제가 발생합니다; 해당 파일들에서 하드코딩된 "5.29.4"를 제거하고 대신 서버가 제공하는 기본 Swagger UI
페이지를 사용하거나(예: springdoc이 제공하는 /swagger-ui/index.html로 대체) 또는 템플릿 변수(예:
${swaggerUiVersion})로 버전을 주입해 build.gradle의 버전과 중앙 관리하도록 변경하세요; 대상 식별자로는 링크/스크립트
태그 내 "swagger-ui.css", "swagger-ui-bundle.js",
"swagger-ui-standalone-preset.js"와 빌드 파일인 build.gradle을 참고해 수정하십시오.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: CHILL

Plan: Pro

Run ID: 18ccb1eb-f7f5-49d4-b672-6b59e11f3b1c

📥 Commits

Reviewing files that changed from the base of the PR and between 98865ac and 9dbe512.

📒 Files selected for processing (13)
  • build.gradle
  • docs/openapi/README.md
  • docs/openapi/openapi.json
  • src/main/java/com/gitranker/api/domain/auth/AuthController.java
  • src/main/java/com/gitranker/api/domain/badge/BadgeController.java
  • src/main/java/com/gitranker/api/domain/ranking/RankingController.java
  • src/main/java/com/gitranker/api/domain/user/UserController.java
  • src/main/java/com/gitranker/api/global/config/OpenApiConfig.java
  • src/main/java/com/gitranker/api/global/config/SecurityConfig.java
  • src/main/resources/application.yml
  • src/main/resources/static/swagger-ui/index.html
  • src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java
  • src/test/resources/application-openapi.yml

build.gradle Outdated
Comment on lines +30 to +31
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.16'
implementation 'org.webjars:swagger-ui:5.29.4'
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

springdoc-openapi latest version 2025

💡 Result:

The latest version of springdoc-openapi is 3.0.2, released on 2026-02-28. This is the most recent stable release for the v3 branch, supporting Spring Boot 4.0.3 and Spring Framework 7. For Spring Boot 3.x, use the latest v2 release, which is 2.8.16 (released 2026-02-28).

Citations:


🌐 Web query:

swagger-ui latest version 2026

💡 Result:

The latest version of Swagger UI is v5.32.1, released on 2026-03-17.

Citations:


swagger-ui 버전 업데이트 필요

springdoc-openapi-starter-webmvc-api:2.8.16은 최신 버전이 맞으나, swagger-ui:5.29.4는 최신 버전인 v5.32.1로 업데이트해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@build.gradle` around lines 30 - 31, Update the swagger-ui dependency version
in build.gradle: replace the existing implementation
'org.webjars:swagger-ui:5.29.4' with the newer release
'org.webjars:swagger-ui:5.32.1' so the dependency coordinates
(org.webjars:swagger-ui) point to v5.32.1; keep the
springdoc-openapi-starter-webmvc-api dependency unchanged.

@@ -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":"http://localhost","description":"Generated server url"}],"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":{"200":{"description":"OK"}},"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"}}}} No newline at end of file
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

DELETE /api/v1/users/me 응답 코드가 실제 구현과 다릅니다.

OpenAPI 스펙에서 deleteMyAccount 응답이 "200":{"description":"OK"}로 문서화되어 있지만, UserController.deleteMyAccount()ResponseEntity.noContent().build()를 반환하여 실제로는 HTTP 204를 응답합니다.

컨트롤러에 @ApiResponse 어노테이션을 추가하여 올바른 응답 코드를 명시하세요:

`@Operation`(...)
`@io.swagger.v3.oas.annotations.responses.ApiResponse`(responseCode = "204", description = "No Content")
public ResponseEntity<Void> deleteMyAccount(...) {
🧰 Tools
🪛 Checkov (3.2.508)

[high] 1: Ensure that the global security field has rules defined

(CKV_OPENAPI_4)


[high] 1: Ensure that security operations is not empty.

(CKV_OPENAPI_5)


[high] 1: Ensure that API keys are not sent over cleartext

(CKV_OPENAPI_20)


[medium] 1: Ensure that arrays have a maximum number of items

(CKV_OPENAPI_21)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` at line 1, The OpenAPI spec documents
deleteMyAccount as returning 200 but UserController.deleteMyAccount() actually
returns HTTP 204; update the controller and/or spec so they match: add
`@io.swagger.v3.oas.annotations.responses.ApiResponse`(responseCode = "204",
description = "No Content") to the UserController.deleteMyAccount(...) method
(alongside `@Operation`) and regenerate/update the OpenAPI definition so the
/api/v1/users/me DELETE operation uses response code 204 instead of 200.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

springdoc openapi server url configuration

💡 Result:

In springdoc-openapi, there is no direct application property like springdoc.openapi.servers.url to configure the OpenAPI servers URL (as of the latest properties docs updated 2024-09). By default, springdoc generates a server URL dynamically from the incoming HTTP request (e.g., current host/port/context-path), which appears in Swagger UI as "Generated server url". This can be incorrect behind proxies unless X-Forwarded-* headers are configured. To configure custom server URLs: 1. Use the @OpenAPIDefinition annotation on a configuration class or main application class: import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.servers.Server; @OpenAPIDefinition(servers = { @Server(url = "https://example.com/api", description = "Production"), @Server(url = "/", description = "Relative (default)") }) You can inject properties: @Server(url = "${swagger.url:http://localhost:8080}"). 2. Define a custom OpenAPI bean: import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class OpenApiConfig { @Bean public OpenAPI customOpenAPI { return new OpenAPI .servers(java.util.List.of( new Server.url("https://example.com/api").description("Production"), new Server.url("http://localhost:8080").description("Development") )); } } Note: Avoid combining @OpenAPIDefinition with a custom OpenAPI bean, as it may conflict; remove the annotation if using the bean. 3. For fine-grained customization of the generated server (e.g., behind proxy), implement ServerBaseUrlCustomizer: import org.springdoc.core.customizers.ServerBaseUrlCustomizer; import org.springframework.stereotype.Component; import jakarta.servlet.http.HttpServletRequest; @Component public class CustomServerBaseUrl implements ServerBaseUrlCustomizer { @Override public String customize(HttpServletRequest request, String baseServerUrl) { // e.g., force HTTPS or adjust path return baseServerUrl.replace("http://", "https://"); } } 4. Recent development (post-2025): YAML properties like springdoc.open-api.servers[0].url may be supported in newer versions, but confirm in release notes. For proxy/reverse proxy issues, ensure server.forward-headers-strategy=native (Undertow) or equivalent, and proxy sends X-Forwarded-Proto, X-Forwarded-Host. These methods override the default generated server in /v3/api-docs and Swagger UI dropdown.

Citations:


🏁 Script executed:

# Find OpenAPI configuration files
find . -type f -name "*OpenApi*" -o -name "*Swagger*" | grep -E "\.(java|yml|yaml|properties)$" | head -20

Repository: alexization/git-ranker

Length of output: 192


🏁 Script executed:

# Search for springdoc configuration in properties/yaml files
fd -e properties -e yml -e yaml | xargs grep -l "springdoc" 2>/dev/null | head -10

Repository: alexization/git-ranker

Length of output: 100


🏁 Script executed:

# Search for server configuration in Java code
rg -t java "Server\(" --max-count 20

Repository: alexization/git-ranker

Length of output: 48


🏁 Script executed:

cat -n src/main/java/com/gitranker/api/global/config/OpenApiConfig.java

Repository: alexization/git-ranker

Length of output: 2847


🏁 Script executed:

cat -n src/main/resources/application.yml | head -100

Repository: alexization/git-ranker

Length of output: 1532


OpenApiConfig에서 프로덕션 서버 URL을 명시적으로 구성하세요.

현재 OpenApiConfig에는 서버 URL이 명시적으로 설정되지 않아, springdoc이 요청 컨텍스트에서 자동 생성한 http://localhost가 OpenAPI 스펙에 포함됩니다. 프로덕션 환경에서는 HTTPS 기반의 실제 API 서버 URL이 필요합니다.

OpenApiConfig.gitRankerOpenApi() 메서드의 OpenAPI 빌더 체인에 .servers() 메서드를 추가하여 환경별 서버 URL을 구성하세요:

.servers(List.of(
    new Server().url("https://api.yourdomain.com").description("Production"),
    new Server().url("http://localhost:8080").description("Development")
))

또는 환경 속성을 주입하여 동적으로 구성할 수 있습니다.

🧰 Tools
🪛 Checkov (3.2.508)

[high] 1: Ensure that the global security field has rules defined

(CKV_OPENAPI_4)


[high] 1: Ensure that security operations is not empty.

(CKV_OPENAPI_5)


[high] 1: Ensure that API keys are not sent over cleartext

(CKV_OPENAPI_20)


[medium] 1: Ensure that arrays have a maximum number of items

(CKV_OPENAPI_21)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/openapi/openapi.json` at line 1, The OpenAPI spec currently contains a
default http://localhost server because OpenApiConfig.gitRankerOpenApi() doesn't
set servers; update gitRankerOpenApi() to call the OpenAPI builder's
.servers(...) and add Server entries for production and development (or inject
an environment property) so the produced spec includes the correct HTTPS
production URL (use Server class and .url/.description in the builder chain).

Comment on lines +69 to +78
private void persistIfRequested(String responseBody) throws IOException {
String outputPath = System.getProperty(OPENAPI_OUTPUT_PROPERTY);
if (outputPath == null || outputPath.isBlank()) {
return;
}

Path outputFile = Path.of(outputPath);
Files.createDirectories(outputFile.getParent());
Files.writeString(outputFile, responseBody);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

outputFile.getParent()null을 반환할 수 있습니다.

outputPath가 부모 디렉토리 없이 파일명만 지정된 경우(예: "openapi.json"), Path.of(outputPath).getParent()null을 반환하며 Files.createDirectories(null) 호출 시 NullPointerException이 발생합니다.

🛡️ null 체크 추가 제안
     private void persistIfRequested(String responseBody) throws IOException {
         String outputPath = System.getProperty(OPENAPI_OUTPUT_PROPERTY);
         if (outputPath == null || outputPath.isBlank()) {
             return;
         }

         Path outputFile = Path.of(outputPath);
-        Files.createDirectories(outputFile.getParent());
+        Path parent = outputFile.getParent();
+        if (parent != null) {
+            Files.createDirectories(parent);
+        }
         Files.writeString(outputFile, responseBody);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private void persistIfRequested(String responseBody) throws IOException {
String outputPath = System.getProperty(OPENAPI_OUTPUT_PROPERTY);
if (outputPath == null || outputPath.isBlank()) {
return;
}
Path outputFile = Path.of(outputPath);
Files.createDirectories(outputFile.getParent());
Files.writeString(outputFile, responseBody);
}
private void persistIfRequested(String responseBody) throws IOException {
String outputPath = System.getProperty(OPENAPI_OUTPUT_PROPERTY);
if (outputPath == null || outputPath.isBlank()) {
return;
}
Path outputFile = Path.of(outputPath);
Path parent = outputFile.getParent();
if (parent != null) {
Files.createDirectories(parent);
}
Files.writeString(outputFile, responseBody);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java` around lines 69 -
78, persistIfRequested currently calls
Files.createDirectories(outputFile.getParent()) but outputFile.getParent() can
be null when OPENAPI_OUTPUT_PROPERTY provides a filename with no parent (e.g.,
"openapi.json"), causing a NullPointerException; modify persistIfRequested to
check whether outputFile.getParent() is non-null before calling
Files.createDirectories (i.e., only create directories when
outputFile.getParent() != null), then proceed to Files.writeString(outputFile,
responseBody).

- 회원 탈퇴 204 응답과 서버 URL을 계약에 반영
- Swagger UI WebJar 버전을 빌드 변수로 중앙화
- OpenAPI 테스트와 스냅샷 재생성 검증을 보강
@alexization
Copy link
Copy Markdown
Owner Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d607b63e4f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +67 to +70
security = {
@SecurityRequirement(name = "bearerAuth"),
@SecurityRequirement(name = "accessTokenCookie")
}
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 👍 / 👎.

@alexization alexization merged commit 75c7720 into develop Mar 24, 2026
3 checks passed
@alexization alexization deleted the feat/grb-01-openapi-contract branch March 24, 2026 07:30
alexization added a commit that referenced this pull request Mar 24, 2026
* build: 백엔드 OpenAPI 계약 생성 기반 추가

- springdoc 기반 OpenAPI 계약과 Swagger UI 경로를 추가
- /api/v1/** 보안 스키마와 엔드포인트 설명을 명시
- openapi.json 생성 태스크와 검증 테스트를 추가

* fix: OpenAPI 계약 리뷰 피드백 반영

- 회원 탈퇴 204 응답과 서버 URL을 계약에 반영
- Swagger UI WebJar 버전을 빌드 변수로 중앙화
- OpenAPI 테스트와 스냅샷 재생성 검증을 보강
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant