diff --git a/build.gradle b/build.gradle index b599677..2cfa78d 100644 --- a/build.gradle +++ b/build.gradle @@ -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' @@ -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' @@ -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' } @@ -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 @@ -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') +} diff --git a/docs/openapi/README.md b/docs/openapi/README.md new file mode 100644 index 0000000..fe7ab82 --- /dev/null +++ b/docs/openapi/README.md @@ -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 ` 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/**`. diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json new file mode 100644 index 0000000..6833fa3 --- /dev/null +++ b/docs/openapi/openapi.json @@ -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 ` 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 ` 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 diff --git a/src/main/java/com/gitranker/api/domain/auth/AuthController.java b/src/main/java/com/gitranker/api/domain/auth/AuthController.java index 75fecb3..da7a122 100644 --- a/src/main/java/com/gitranker/api/domain/auth/AuthController.java +++ b/src/main/java/com/gitranker/api/domain/auth/AuthController.java @@ -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,6 +23,7 @@ @Slf4j @RestController +@Tag(name = "Auth") @RequestMapping("/api/v1/auth") @RequiredArgsConstructor public class AuthController { @@ -27,6 +31,14 @@ 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> me(@AuthenticationPrincipal User user) { if (user == null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(ErrorType.UNAUTHORIZED_ACCESS)); @@ -36,6 +48,11 @@ public ResponseEntity> 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> refreshToken(HttpServletRequest request, HttpServletResponse response) { String refreshToken = CookieUtils.extractRefreshToken(request); authService.refreshAccessToken(refreshToken, response); @@ -44,6 +61,14 @@ public ResponseEntity> 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") + } + ) public ResponseEntity> logout( @AuthenticationPrincipal User user, HttpServletRequest request, @@ -60,6 +85,14 @@ public ResponseEntity> 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> logoutAll( @AuthenticationPrincipal User user, HttpServletRequest request, diff --git a/src/main/java/com/gitranker/api/domain/badge/BadgeController.java b/src/main/java/com/gitranker/api/domain/badge/BadgeController.java index f8acef1..90a1090 100644 --- a/src/main/java/com/gitranker/api/domain/badge/BadgeController.java +++ b/src/main/java/com/gitranker/api/domain/badge/BadgeController.java @@ -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; @@ -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 getBadge(@PathVariable String nodeId) { String svgContent = badgeService.generateBadge(nodeId); @@ -35,6 +39,7 @@ public ResponseEntity 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 getBadgeByTier(@PathVariable Tier tier) { String svgContent = badgeService.generateBadgeByTier(tier); @@ -42,4 +47,4 @@ public ResponseEntity getBadgeByTier(@PathVariable Tier tier) { .contentType(MediaType.valueOf("image/svg+xml")) .body(svgContent); } -} \ No newline at end of file +} diff --git a/src/main/java/com/gitranker/api/domain/ranking/RankingController.java b/src/main/java/com/gitranker/api/domain/ranking/RankingController.java index 670592e..76a36f4 100644 --- a/src/main/java/com/gitranker/api/domain/ranking/RankingController.java +++ b/src/main/java/com/gitranker/api/domain/ranking/RankingController.java @@ -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; @@ -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 getRankings( @RequestParam(defaultValue = "0") @Min(value = 0, message = "{validation.ranking.page.min}") int page, @RequestParam(required = false) Tier tier diff --git a/src/main/java/com/gitranker/api/domain/user/UserController.java b/src/main/java/com/gitranker/api/domain/user/UserController.java index e3203ff..a3597bd 100644 --- a/src/main/java/com/gitranker/api/domain/user/UserController.java +++ b/src/main/java/com/gitranker/api/domain/user/UserController.java @@ -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 getUser( @PathVariable @Pattern(regexp = USERNAME_PATTERN, message = USERNAME_MESSAGE) String username ) { @@ -37,6 +42,14 @@ public ApiResponse 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 refreshUser( @PathVariable @Pattern(regexp = USERNAME_PATTERN, message = USERNAME_MESSAGE) String username, @AuthenticationPrincipal User user @@ -55,6 +68,15 @@ public ApiResponse refreshUser( } @DeleteMapping("/me") + @Operation( + summary = "Delete the authenticated user's account", + description = "Deletes the current account and clears authentication cookies.", + security = { + @SecurityRequirement(name = "bearerAuth"), + @SecurityRequirement(name = "accessTokenCookie") + } + ) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "No Content") public ResponseEntity deleteMyAccount( @AuthenticationPrincipal User user, HttpServletResponse response @@ -68,4 +90,3 @@ public ResponseEntity deleteMyAccount( return ResponseEntity.noContent().build(); } } - diff --git a/src/main/java/com/gitranker/api/global/config/OpenApiConfig.java b/src/main/java/com/gitranker/api/global/config/OpenApiConfig.java new file mode 100644 index 0000000..93fa6ea --- /dev/null +++ b/src/main/java/com/gitranker/api/global/config/OpenApiConfig.java @@ -0,0 +1,56 @@ +package com.gitranker.api.global.config; + +import com.gitranker.api.global.util.CookieUtils; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI gitRankerOpenApi() { + return new OpenAPI() + .info(new Info() + .title("Git Ranker API") + .version("v1") + .description(""" + Machine-readable contract for Git Ranker's public `/api/v1/**` endpoints. + + Authentication model: + - Protected endpoints accept either an `Authorization: Bearer ` header or the `accessToken` cookie. + - `/api/v1/auth/refresh` uses the `refreshToken` cookie. + - Initial sign-in starts with the GitHub OAuth2 redirect flow exposed by Spring Security outside `/api/v1/**`. + """.stripIndent())) + .servers(List.of( + new Server() + .url("https://www.git-ranker.com") + .description("Production"), + new Server() + .url("http://localhost:8080") + .description("Local development") + )) + .components(new Components() + .addSecuritySchemes("bearerAuth", new SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + .description("Send `Authorization: Bearer ` for protected API calls.")) + .addSecuritySchemes("accessTokenCookie", new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.COOKIE) + .name(CookieUtils.ACCESS_TOKEN_COOKIE_NAME) + .description("Browser session alternative to bearerAuth.")) + .addSecuritySchemes("refreshTokenCookie", new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.COOKIE) + .name(CookieUtils.REFRESH_TOKEN_COOKIE_NAME) + .description("Required by refresh and logout flows that rotate or revoke session tokens."))); + } +} diff --git a/src/main/java/com/gitranker/api/global/config/SecurityConfig.java b/src/main/java/com/gitranker/api/global/config/SecurityConfig.java index ce78ab6..9f2ab2b 100644 --- a/src/main/java/com/gitranker/api/global/config/SecurityConfig.java +++ b/src/main/java/com/gitranker/api/global/config/SecurityConfig.java @@ -50,6 +50,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/api/v1/users/{username}", "/api/v1/auth/refresh", "/api/v1/badges/**", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/webjars/**", "/login/**", "/oauth2/**", "/actuator/health", diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 25503fa..25498bd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,12 @@ spring: batch: chunk-size: 100 +springdoc: + paths-to-match: /api/v1/** + swagger-ui: + operations-sorter: alpha + tags-sorter: alpha + management: server: port: 9090 @@ -49,4 +55,4 @@ server: enabled: true app: - timezone: Asia/Seoul \ No newline at end of file + timezone: Asia/Seoul diff --git a/src/main/resources/static/swagger-ui/index.html b/src/main/resources/static/swagger-ui/index.html new file mode 100644 index 0000000..fbe4c42 --- /dev/null +++ b/src/main/resources/static/swagger-ui/index.html @@ -0,0 +1,31 @@ + + + + + + Git Ranker API Docs + + + +
+ + + + + + diff --git a/src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java b/src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java new file mode 100644 index 0000000..ec71411 --- /dev/null +++ b/src/test/java/com/gitranker/api/docs/OpenApiDocsTest.java @@ -0,0 +1,85 @@ +package com.gitranker.api.docs; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) +@AutoConfigureMockMvc +@ActiveProfiles("openapi") +class OpenApiDocsTest { + + private static final String OPENAPI_OUTPUT_PROPERTY = "openapi.output"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("OpenAPI JSON은 공개 /api/v1 계약과 보안 스키마를 노출한다") + void shouldExposeOpenApiJsonForPublicApi() throws Exception { + MvcResult result = mockMvc.perform(get("/v3/api-docs")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.paths['/api/v1/ranking'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/users/{username}'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/users/{username}/refresh'].post").exists()) + .andExpect(jsonPath("$.paths['/api/v1/users/me'].delete").exists()) + .andExpect(jsonPath("$.paths['/api/v1/auth/me'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/auth/refresh'].post").exists()) + .andExpect(jsonPath("$.paths['/api/v1/auth/logout'].post").exists()) + .andExpect(jsonPath("$.paths['/api/v1/auth/logout/all'].post").exists()) + .andExpect(jsonPath("$.paths['/api/v1/badges/{nodeId}'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/badges/{tier}/badge'].get").exists()) + .andExpect(jsonPath("$.paths['/api/v1/users/me'].delete.responses['204']").exists()) + .andExpect(jsonPath("$.components.securitySchemes.bearerAuth").exists()) + .andExpect(jsonPath("$.components.securitySchemes.accessTokenCookie").exists()) + .andExpect(jsonPath("$.components.securitySchemes.refreshTokenCookie").exists()) + .andExpect(jsonPath("$.servers[0].url").value("https://www.git-ranker.com")) + .andExpect(jsonPath("$.servers[1].url").value("http://localhost:8080")) + .andReturn(); + + String responseBody = result.getResponse().getContentAsString(); + assertThat(responseBody).doesNotContain("\"/actuator/health\""); + + persistIfRequested(responseBody); + } + + @Test + @DisplayName("Swagger UI는 브라우저 경로에서 노출된다") + void shouldExposeSwaggerUi() throws Exception { + mockMvc.perform(get("/swagger-ui/index.html")) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML)) + .andExpect(content().string(org.hamcrest.Matchers.containsString("swagger-ui"))); + } + + 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); + } +} diff --git a/src/test/resources/application-openapi.yml b/src/test/resources/application-openapi.yml new file mode 100644 index 0000000..761ee61 --- /dev/null +++ b/src/test/resources/application-openapi.yml @@ -0,0 +1,49 @@ +spring: + config: + activate: + on-profile: openapi + + datasource: + url: jdbc:h2:mem:openapi;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + username: sa + password: + driver-class-name: org.h2.Driver + + jpa: + hibernate: + ddl-auto: none + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + + security: + oauth2: + client: + registration: + github: + client-id: openapi-client + client-secret: openapi-secret + scope: + - read:user + - user:email + redirect-uri: http://localhost/login/oauth2/code/github + +jwt: + secret: b3BlbmFwaS10ZXN0LXNlY3JldC1vcGVuYXBpLXRlc3Qtc2VjcmV0LW9wZW5hcGktdGVzdC1zZWNyZXQtb3BlbmFwaS10ZXN0LXNlY3JldC0= + access-token-expiration: 3600000 + refresh-token-expiration: 1209600000 + +github: + api: + graphql-url: https://api.github.com/graphql + tokens: openapi-token + threshold: 100 + +app: + cors: + allowed-origins: http://localhost:3000 + oauth2: + authorized-redirect-uri: http://localhost:3000/auth/callback + cookie: + domain: localhost + secure: false