diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2a43438..b1410af 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ jjwt-api = { module = "io.jsonwebtoken:jjwt-api", version.ref = "jjwt" } jjwt-impl = { module = "io.jsonwebtoken:jjwt-impl", version.ref = "jjwt" } jjwt-jackson = { module = "io.jsonwebtoken:jjwt-jackson", version.ref = "jjwt" } springdoc-openapi-webmvc-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } +springdoc-openapi-webflux-ui = { module = "org.springdoc:springdoc-openapi-starter-webflux-ui", version.ref = "springdoc" } micrometer-context-propagation = { module = "io.micrometer:context-propagation" } mockk = { module = "io.mockk:mockk", version.ref = "mockk" } diff --git a/rush-auth/src/main/kotlin/com/rush/auth/config/OpenApiConfig.kt b/rush-auth/src/main/kotlin/com/rush/auth/config/OpenApiConfig.kt new file mode 100644 index 0000000..e77b9cf --- /dev/null +++ b/rush-auth/src/main/kotlin/com/rush/auth/config/OpenApiConfig.kt @@ -0,0 +1,28 @@ +package com.rush.auth.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * 게이트웨이 Swagger UI의 Authorize에 넣은 JWT가 모든 요청의 Authorization 헤더로 붙도록 Bearer 인증을 정의합니다. + */ +@Configuration +class OpenApiConfig { + @Bean + fun openApi(): OpenAPI = + OpenAPI() + .components( + Components().addSecuritySchemes( + BEARER, + SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"), + ), + ).addSecurityItem(SecurityRequirement().addList(BEARER)) + + companion object { + const val BEARER = "bearer" + } +} diff --git a/rush-auth/src/test/kotlin/com/rush/auth/config/OpenApiConfigTest.kt b/rush-auth/src/test/kotlin/com/rush/auth/config/OpenApiConfigTest.kt new file mode 100644 index 0000000..405f8da --- /dev/null +++ b/rush-auth/src/test/kotlin/com/rush/auth/config/OpenApiConfigTest.kt @@ -0,0 +1,18 @@ +package com.rush.auth.config + +import io.swagger.v3.oas.models.security.SecurityScheme +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OpenApiConfigTest { + @Test + fun `Bearer 인증을 정의하고 전체 API에 적용한다`() { + val openApi = OpenApiConfig().openApi() + + val scheme = openApi.components.securitySchemes.getValue(OpenApiConfig.BEARER) + assertEquals(SecurityScheme.Type.HTTP, scheme.type) + assertEquals("bearer", scheme.scheme) + assertTrue(openApi.security.any { it.containsKey(OpenApiConfig.BEARER) }) + } +} diff --git a/rush-config/src/main/resources/config/rush-auth.yml b/rush-config/src/main/resources/config/rush-auth.yml index 91b0717..08621aa 100644 --- a/rush-config/src/main/resources/config/rush-auth.yml +++ b/rush-config/src/main/resources/config/rush-auth.yml @@ -1,3 +1,10 @@ # rush-auth 전용 설정 server: port: 8081 + # 게이트웨이가 넘긴 X-Forwarded-*로 Swagger 서버 주소를 게이트웨이 주소로 만듭니다. + forward-headers-strategy: framework + +# 게이트웨이 Swagger UI가 /auth/** 라우트로 가져갈 수 있게 문서 경로에 prefix를 붙입니다. +springdoc: + api-docs: + path: /auth/v3/api-docs diff --git a/rush-config/src/main/resources/config/rush-minigame.yml b/rush-config/src/main/resources/config/rush-minigame.yml index c234ae7..ea20c2e 100644 --- a/rush-config/src/main/resources/config/rush-minigame.yml +++ b/rush-config/src/main/resources/config/rush-minigame.yml @@ -1,3 +1,10 @@ # rush-minigame 전용 설정 server: port: 8083 + # 게이트웨이가 넘긴 X-Forwarded-*로 Swagger 서버 주소를 게이트웨이 주소로 만듭니다. + forward-headers-strategy: framework + +# 게이트웨이 Swagger UI가 /minigame/** 라우트로 가져갈 수 있게 문서 경로에 prefix를 붙입니다. +springdoc: + api-docs: + path: /minigame/v3/api-docs diff --git a/rush-config/src/main/resources/config/rush-shop.yml b/rush-config/src/main/resources/config/rush-shop.yml index e9e374b..6408146 100644 --- a/rush-config/src/main/resources/config/rush-shop.yml +++ b/rush-config/src/main/resources/config/rush-shop.yml @@ -1,3 +1,10 @@ # rush-shop 전용 설정 server: port: 8082 + # 게이트웨이가 넘긴 X-Forwarded-*로 Swagger 서버 주소를 게이트웨이 주소로 만듭니다. + forward-headers-strategy: framework + +# 게이트웨이 Swagger UI가 /shop/** 라우트로 가져갈 수 있게 문서 경로에 prefix를 붙입니다. +springdoc: + api-docs: + path: /shop/v3/api-docs diff --git a/rush-config/src/main/resources/config/rush-stage.yml b/rush-config/src/main/resources/config/rush-stage.yml index 6d22543..34ce9b6 100644 --- a/rush-config/src/main/resources/config/rush-stage.yml +++ b/rush-config/src/main/resources/config/rush-stage.yml @@ -1,3 +1,10 @@ # rush-stage 전용 설정 server: port: 8084 + # 게이트웨이가 넘긴 X-Forwarded-*로 Swagger 서버 주소를 게이트웨이 주소로 만듭니다. + forward-headers-strategy: framework + +# 게이트웨이 Swagger UI가 /stage/** 라우트로 가져갈 수 있게 문서 경로에 prefix를 붙입니다. +springdoc: + api-docs: + path: /stage/v3/api-docs diff --git a/rush-gateway/README.md b/rush-gateway/README.md index 6d6c9ec..38fe581 100644 --- a/rush-gateway/README.md +++ b/rush-gateway/README.md @@ -63,6 +63,14 @@ WebSocket을 제외한 라우트는 서킷브레이커(Resilience4j)를 거칩 | `TRUSTED_PROXIES` | 없음 | 앞단 LB/프록시 대역 | | `CORS_ALLOWED_ORIGINS` | 없음 | CORS 허용 Origin | +## Swagger + +`http://localhost:8080/swagger-ui.html`에서 auth, shop, minigame, stage, user, sp의 API 문서를 함께 볼 수 있습니다. + +- 각 서비스는 `/{prefix}/v3/api-docs`에 문서를 냅니다. 게이트웨이가 이 경로를 라우트로 가져오며, 토큰 없이 열립니다. +- "Try it out" 요청은 게이트웨이(8080)로 갑니다. 인증이 필요한 API는 Authorize에 JWT를 넣으세요. +- live, chat은 WebSocket뿐이라 제외했습니다. + ## 실행 ```bash diff --git a/rush-gateway/build.gradle.kts b/rush-gateway/build.gradle.kts index af869af..a162a68 100644 --- a/rush-gateway/build.gradle.kts +++ b/rush-gateway/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.security) implementation(libs.micrometer.context.propagation) + implementation(libs.springdoc.openapi.webflux.ui) implementation(libs.jjwt.api) runtimeOnly(libs.jjwt.impl) runtimeOnly(libs.jjwt.jackson) diff --git a/rush-gateway/src/main/resources/application.yml b/rush-gateway/src/main/resources/application.yml index a15632d..8030033 100644 --- a/rush-gateway/src/main/resources/application.yml +++ b/rush-gateway/src/main/resources/application.yml @@ -101,6 +101,18 @@ spring: uri: ${CHAT_URL:ws://localhost:8088} predicates: [Path=/chat/**] +# 각 서비스의 OpenAPI 문서를 라우트로 가져와 /swagger-ui.html 한 곳에서 봅니다. 문서 경로는 토큰 없이 열립니다. +springdoc: + swagger-ui: + path: /swagger-ui.html + urls: + - { name: auth, url: /auth/v3/api-docs } + - { name: shop, url: /shop/v3/api-docs } + - { name: minigame, url: /minigame/v3/api-docs } + - { name: stage, url: /stage/v3/api-docs } + - { name: user, url: /user/v3/api-docs } + - { name: sp, url: /sp/v3/api-docs } + resilience4j: circuitbreaker: configs: diff --git a/rush-gateway/src/test/kotlin/com/rush/gateway/swagger/SwaggerAggregationTest.kt b/rush-gateway/src/test/kotlin/com/rush/gateway/swagger/SwaggerAggregationTest.kt new file mode 100644 index 0000000..5c741e5 --- /dev/null +++ b/rush-gateway/src/test/kotlin/com/rush/gateway/swagger/SwaggerAggregationTest.kt @@ -0,0 +1,90 @@ +package com.rush.gateway.swagger + +import com.sun.net.httpserver.HttpServer +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource +import org.springframework.test.web.reactive.server.WebTestClient +import java.net.InetSocketAddress + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = [ + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false", + "jwt.secret=test-secret-key-for-gateway-swagger-test-0123456789", + ], +) +@AutoConfigureWebTestClient +class SwaggerAggregationTest { + @Autowired + lateinit var client: WebTestClient + + @Test + fun `Swagger UI가 열린다`() { + client + .get() + .uri("/swagger-ui.html") + .exchange() + .expectStatus() + .is3xxRedirection + } + + @Test + fun `Swagger UI 설정에 6개 서비스 문서가 등록된다`() { + client + .get() + .uri("/v3/api-docs/swagger-config") + .exchange() + .expectStatus() + .isOk + .expectBody() + .jsonPath("$.urls.length()") + .isEqualTo(6) + .jsonPath("$.urls[?(@.name == 'shop')].url") + .isEqualTo("/shop/v3/api-docs") + } + + @Test + fun `서비스 문서는 토큰 없이 원문 그대로 가져온다`() { + client + .get() + .uri("/user/v3/api-docs") + .exchange() + .expectStatus() + .isOk + .expectBody() + .json(API_DOCS, true) + } + + companion object { + private const val API_DOCS = """{"openapi":"3.0.0","info":{"title":"rush-user","version":"1.0"},"paths":{}}""" + + private val docsServer = + HttpServer.create(InetSocketAddress("localhost", 0), 0).apply { + createContext("/user/v3/api-docs") { exchange -> + val body = API_DOCS.toByteArray() + exchange.responseHeaders.set("Content-Type", "application/json") + exchange.sendResponseHeaders(200, body.size.toLong()) + exchange.responseBody.use { it.write(body) } + } + start() + } + + @JvmStatic + @DynamicPropertySource + fun routes(registry: DynamicPropertyRegistry) { + registry.add("USER_URL") { "http://localhost:${docsServer.address.port}" } + } + + @JvmStatic + @AfterAll + fun stopServer() { + docsServer.stop(0) + } + } +} diff --git a/rush-minigame/src/main/kotlin/com/rush/minigame/config/OpenApiConfig.kt b/rush-minigame/src/main/kotlin/com/rush/minigame/config/OpenApiConfig.kt new file mode 100644 index 0000000..d8488af --- /dev/null +++ b/rush-minigame/src/main/kotlin/com/rush/minigame/config/OpenApiConfig.kt @@ -0,0 +1,28 @@ +package com.rush.minigame.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * 게이트웨이 Swagger UI의 Authorize에 넣은 JWT가 모든 요청의 Authorization 헤더로 붙도록 Bearer 인증을 정의합니다. + */ +@Configuration +class OpenApiConfig { + @Bean + fun openApi(): OpenAPI = + OpenAPI() + .components( + Components().addSecuritySchemes( + BEARER, + SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"), + ), + ).addSecurityItem(SecurityRequirement().addList(BEARER)) + + companion object { + const val BEARER = "bearer" + } +} diff --git a/rush-minigame/src/test/kotlin/com/rush/minigame/config/OpenApiConfigTest.kt b/rush-minigame/src/test/kotlin/com/rush/minigame/config/OpenApiConfigTest.kt new file mode 100644 index 0000000..8caf0fc --- /dev/null +++ b/rush-minigame/src/test/kotlin/com/rush/minigame/config/OpenApiConfigTest.kt @@ -0,0 +1,18 @@ +package com.rush.minigame.config + +import io.swagger.v3.oas.models.security.SecurityScheme +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OpenApiConfigTest { + @Test + fun `Bearer 인증을 정의하고 전체 API에 적용한다`() { + val openApi = OpenApiConfig().openApi() + + val scheme = openApi.components.securitySchemes.getValue(OpenApiConfig.BEARER) + assertEquals(SecurityScheme.Type.HTTP, scheme.type) + assertEquals("bearer", scheme.scheme) + assertTrue(openApi.security.any { it.containsKey(OpenApiConfig.BEARER) }) + } +} diff --git a/rush-shop/src/main/kotlin/com/rush/shop/config/OpenApiConfig.kt b/rush-shop/src/main/kotlin/com/rush/shop/config/OpenApiConfig.kt new file mode 100644 index 0000000..ed52c3d --- /dev/null +++ b/rush-shop/src/main/kotlin/com/rush/shop/config/OpenApiConfig.kt @@ -0,0 +1,28 @@ +package com.rush.shop.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * 게이트웨이 Swagger UI의 Authorize에 넣은 JWT가 모든 요청의 Authorization 헤더로 붙도록 Bearer 인증을 정의합니다. + */ +@Configuration +class OpenApiConfig { + @Bean + fun openApi(): OpenAPI = + OpenAPI() + .components( + Components().addSecuritySchemes( + BEARER, + SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"), + ), + ).addSecurityItem(SecurityRequirement().addList(BEARER)) + + companion object { + const val BEARER = "bearer" + } +} diff --git a/rush-shop/src/test/kotlin/com/rush/shop/config/OpenApiConfigTest.kt b/rush-shop/src/test/kotlin/com/rush/shop/config/OpenApiConfigTest.kt new file mode 100644 index 0000000..1b006a0 --- /dev/null +++ b/rush-shop/src/test/kotlin/com/rush/shop/config/OpenApiConfigTest.kt @@ -0,0 +1,18 @@ +package com.rush.shop.config + +import io.swagger.v3.oas.models.security.SecurityScheme +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OpenApiConfigTest { + @Test + fun `Bearer 인증을 정의하고 전체 API에 적용한다`() { + val openApi = OpenApiConfig().openApi() + + val scheme = openApi.components.securitySchemes.getValue(OpenApiConfig.BEARER) + assertEquals(SecurityScheme.Type.HTTP, scheme.type) + assertEquals("bearer", scheme.scheme) + assertTrue(openApi.security.any { it.containsKey(OpenApiConfig.BEARER) }) + } +} diff --git a/rush-sp/package-lock.json b/rush-sp/package-lock.json index 5b7dd54..1f40675 100644 --- a/rush-sp/package-lock.json +++ b/rush-sp/package-lock.json @@ -12,6 +12,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -1978,6 +1979,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "11.0.24", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.24.tgz", @@ -2276,6 +2283,26 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.8.tgz", @@ -2401,6 +2428,61 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/swagger": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz", + "integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "5.3.0", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.13" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/@nestjs/testing": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.8.tgz", @@ -2763,6 +2845,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", @@ -4115,7 +4204,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -7248,7 +7336,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { @@ -8995,6 +9082,15 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.13", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz", + "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/rush-sp/package.json b/rush-sp/package.json index cb93a23..30b4db7 100644 --- a/rush-sp/package.json +++ b/rush-sp/package.json @@ -23,6 +23,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/rush-sp/src/main.ts b/rush-sp/src/main.ts index 0290631..9b7d363 100644 --- a/rush-sp/src/main.ts +++ b/rush-sp/src/main.ts @@ -1,10 +1,12 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { setupSwagger } from './swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); // 게이트웨이가 /sp/** 를 그대로 넘겨주므로 prefix를 맞춥니다. app.setGlobalPrefix('sp'); + setupSwagger(app); await app.listen(process.env.PORT ?? 8086); } void bootstrap(); diff --git a/rush-sp/src/swagger.ts b/rush-sp/src/swagger.ts new file mode 100644 index 0000000..7e63c6d --- /dev/null +++ b/rush-sp/src/swagger.ts @@ -0,0 +1,21 @@ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +/** + * OpenAPI 문서를 /sp/v3/api-docs 에 냅니다. 게이트웨이 Swagger UI가 이 경로로 문서를 가져옵니다. + * servers를 비워 두면 Swagger UI가 문서를 받아 온 게이트웨이 주소로 요청을 보냅니다. + * Authorize에 넣은 JWT가 모든 요청에 붙도록 Bearer 인증을 전체 API에 적용합니다. + */ +export function setupSwagger(app: INestApplication): void { + const config = new DocumentBuilder() + .setTitle('rush-sp') + .setVersion('1.0') + .addBearerAuth() + .addSecurityRequirements('bearer') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('sp/docs', app, document, { + jsonDocumentUrl: 'sp/v3/api-docs', + yamlDocumentUrl: 'sp/v3/api-docs.yaml', + }); +} diff --git a/rush-sp/test/app.e2e-spec.ts b/rush-sp/test/app.e2e-spec.ts index da70822..9125f0c 100644 --- a/rush-sp/test/app.e2e-spec.ts +++ b/rush-sp/test/app.e2e-spec.ts @@ -3,6 +3,7 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; +import { setupSwagger } from './../src/swagger'; describe('AppController (e2e)', () => { let app: INestApplication; @@ -14,10 +15,27 @@ describe('AppController (e2e)', () => { app = moduleFixture.createNestApplication(); app.setGlobalPrefix('sp'); + setupSwagger(app); await app.init(); }); it('/sp/ping (GET)', () => { return request(app.getHttpServer()).get('/sp/ping').expect(200); }); + + it('/sp/v3/api-docs (GET)', async () => { + const response = await request(app.getHttpServer()) + .get('/sp/v3/api-docs') + .expect(200); + const body = response.body as { + openapi: string; + servers?: unknown[]; + security?: Record[]; + components?: { securitySchemes?: Record }; + }; + expect(body.openapi).toMatch(/^3\./); + expect(body.servers ?? []).toHaveLength(0); + expect(body.components?.securitySchemes?.bearer?.scheme).toBe('bearer'); + expect(body.security).toContainEqual({ bearer: [] }); + }); }); diff --git a/rush-stage/src/main/kotlin/com/rush/stage/config/OpenApiConfig.kt b/rush-stage/src/main/kotlin/com/rush/stage/config/OpenApiConfig.kt new file mode 100644 index 0000000..4f01ec2 --- /dev/null +++ b/rush-stage/src/main/kotlin/com/rush/stage/config/OpenApiConfig.kt @@ -0,0 +1,28 @@ +package com.rush.stage.config + +import io.swagger.v3.oas.models.Components +import io.swagger.v3.oas.models.OpenAPI +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration + +/** + * 게이트웨이 Swagger UI의 Authorize에 넣은 JWT가 모든 요청의 Authorization 헤더로 붙도록 Bearer 인증을 정의합니다. + */ +@Configuration +class OpenApiConfig { + @Bean + fun openApi(): OpenAPI = + OpenAPI() + .components( + Components().addSecuritySchemes( + BEARER, + SecurityScheme().type(SecurityScheme.Type.HTTP).scheme("bearer").bearerFormat("JWT"), + ), + ).addSecurityItem(SecurityRequirement().addList(BEARER)) + + companion object { + const val BEARER = "bearer" + } +} diff --git a/rush-stage/src/test/kotlin/com/rush/stage/config/OpenApiConfigTest.kt b/rush-stage/src/test/kotlin/com/rush/stage/config/OpenApiConfigTest.kt new file mode 100644 index 0000000..af9af3d --- /dev/null +++ b/rush-stage/src/test/kotlin/com/rush/stage/config/OpenApiConfigTest.kt @@ -0,0 +1,18 @@ +package com.rush.stage.config + +import io.swagger.v3.oas.models.security.SecurityScheme +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class OpenApiConfigTest { + @Test + fun `Bearer 인증을 정의하고 전체 API에 적용한다`() { + val openApi = OpenApiConfig().openApi() + + val scheme = openApi.components.securitySchemes.getValue(OpenApiConfig.BEARER) + assertEquals(SecurityScheme.Type.HTTP, scheme.type) + assertEquals("bearer", scheme.scheme) + assertTrue(openApi.security.any { it.containsKey(OpenApiConfig.BEARER) }) + } +} diff --git a/rush-user/package-lock.json b/rush-user/package-lock.json index 7f7cef2..1c5c347 100644 --- a/rush-user/package-lock.json +++ b/rush-user/package-lock.json @@ -12,6 +12,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -1978,6 +1979,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@nestjs/cli": { "version": "11.0.24", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.24.tgz", @@ -2276,6 +2283,26 @@ } } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/platform-express": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.8.tgz", @@ -2401,6 +2428,61 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/swagger": { + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.7.tgz", + "integrity": "sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "5.3.0", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.13" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, "node_modules/@nestjs/testing": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.2.8.tgz", @@ -2763,6 +2845,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.52", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.52.tgz", @@ -4115,7 +4204,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -7248,7 +7336,6 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash.memoize": { @@ -8995,6 +9082,15 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.13", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz", + "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/rush-user/package.json b/rush-user/package.json index 457896d..04f82a3 100644 --- a/rush-user/package.json +++ b/rush-user/package.json @@ -23,6 +23,7 @@ "@nestjs/common": "^11.0.1", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/rush-user/src/main.ts b/rush-user/src/main.ts index 0713ca0..65b1dfa 100644 --- a/rush-user/src/main.ts +++ b/rush-user/src/main.ts @@ -1,10 +1,12 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { setupSwagger } from './swagger'; async function bootstrap() { const app = await NestFactory.create(AppModule); // 게이트웨이가 /user/** 를 그대로 넘겨주므로 prefix를 맞춥니다. app.setGlobalPrefix('user'); + setupSwagger(app); await app.listen(process.env.PORT ?? 8085); } void bootstrap(); diff --git a/rush-user/src/swagger.ts b/rush-user/src/swagger.ts new file mode 100644 index 0000000..2cd75da --- /dev/null +++ b/rush-user/src/swagger.ts @@ -0,0 +1,21 @@ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +/** + * OpenAPI 문서를 /user/v3/api-docs 에 냅니다. 게이트웨이 Swagger UI가 이 경로로 문서를 가져옵니다. + * servers를 비워 두면 Swagger UI가 문서를 받아 온 게이트웨이 주소로 요청을 보냅니다. + * Authorize에 넣은 JWT가 모든 요청에 붙도록 Bearer 인증을 전체 API에 적용합니다. + */ +export function setupSwagger(app: INestApplication): void { + const config = new DocumentBuilder() + .setTitle('rush-user') + .setVersion('1.0') + .addBearerAuth() + .addSecurityRequirements('bearer') + .build(); + const document = SwaggerModule.createDocument(app, config); + SwaggerModule.setup('user/docs', app, document, { + jsonDocumentUrl: 'user/v3/api-docs', + yamlDocumentUrl: 'user/v3/api-docs.yaml', + }); +} diff --git a/rush-user/test/app.e2e-spec.ts b/rush-user/test/app.e2e-spec.ts index cfeed6b..67ff2fb 100644 --- a/rush-user/test/app.e2e-spec.ts +++ b/rush-user/test/app.e2e-spec.ts @@ -3,6 +3,7 @@ import { INestApplication } from '@nestjs/common'; import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; +import { setupSwagger } from './../src/swagger'; describe('AppController (e2e)', () => { let app: INestApplication; @@ -14,10 +15,27 @@ describe('AppController (e2e)', () => { app = moduleFixture.createNestApplication(); app.setGlobalPrefix('user'); + setupSwagger(app); await app.init(); }); it('/user/ping (GET)', () => { return request(app.getHttpServer()).get('/user/ping').expect(200); }); + + it('/user/v3/api-docs (GET)', async () => { + const response = await request(app.getHttpServer()) + .get('/user/v3/api-docs') + .expect(200); + const body = response.body as { + openapi: string; + servers?: unknown[]; + security?: Record[]; + components?: { securitySchemes?: Record }; + }; + expect(body.openapi).toMatch(/^3\./); + expect(body.servers ?? []).toHaveLength(0); + expect(body.components?.securitySchemes?.bearer?.scheme).toBe('bearer'); + expect(body.security).toContainEqual({ bearer: [] }); + }); });