From 2b4cfd7d14f00b3019c499d6632043087e889e09 Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Tue, 3 Nov 2026 18:07:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(gateway):=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=EB=B3=84=20API=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=20=EB=B3=B4=EB=8A=94=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EC=9B=A8=EC=9D=B4=20Swagger=20UI=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle/libs.versions.toml | 1 + .../src/main/resources/config/rush-auth.yml | 7 + .../main/resources/config/rush-minigame.yml | 7 + .../src/main/resources/config/rush-shop.yml | 7 + .../src/main/resources/config/rush-stage.yml | 7 + rush-gateway/README.md | 8 + rush-gateway/build.gradle.kts | 1 + .../src/main/resources/application.yml | 12 ++ .../gateway/swagger/SwaggerAggregationTest.kt | 90 +++++++++++ rush-sp/package-lock.json | 143 ++++++++++++------ rush-sp/package.json | 1 + rush-sp/src/main.ts | 2 + rush-sp/src/swagger.ts | 19 +++ rush-sp/test/app.e2e-spec.ts | 11 ++ rush-user/package-lock.json | 143 ++++++++++++------ rush-user/package.json | 1 + rush-user/src/main.ts | 2 + rush-user/src/swagger.ts | 19 +++ rush-user/test/app.e2e-spec.ts | 11 ++ 19 files changed, 402 insertions(+), 90 deletions(-) create mode 100644 rush-gateway/src/test/kotlin/com/rush/gateway/swagger/SwaggerAggregationTest.kt create mode 100644 rush-sp/src/swagger.ts create mode 100644 rush-user/src/swagger.ts 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-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-sp/package-lock.json b/rush-sp/package-lock.json index 5b7dd54..039c797 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", @@ -2163,49 +2170,6 @@ } } }, - "node_modules/@nestjs/cli/node_modules/webpack/node_modules/webpack": { - "version": "5.112.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.112.2.tgz", - "integrity": "sha512-B2tp1LI9D8GVpPDqprdt12ukTkXXahj9MzqSWJq4faSPgOUM7CdZaGAB6HrL/8E6UwbBpdj4vzWDbKoFnVoU6g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.26.0", - "es-module-lexer": "^2.1.0", - "events": "^3.2.0", - "graceful-fs": "^4.2.11", - "mime-db": "^1.54.0", - "minimizer-webpack-plugin": "^5.13.0", - "schema-utils": "^4.8.1", - "tapable": "^2.3.0", - "watchpack": "^2.6.3", - "webpack-sources": "^3.6.0" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/@nestjs/common": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.8.tgz", @@ -2276,6 +2240,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 +2385,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 +2802,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 +4161,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 +7293,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 +9039,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..56c66c9 --- /dev/null +++ b/rush-sp/src/swagger.ts @@ -0,0 +1,19 @@ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +/** + * OpenAPI 문서를 /sp/v3/api-docs 에 냅니다. 게이트웨이 Swagger UI가 이 경로로 문서를 가져옵니다. + * servers를 비워 두면 Swagger UI가 문서를 받아 온 게이트웨이 주소로 요청을 보냅니다. + */ +export function setupSwagger(app: INestApplication): void { + const config = new DocumentBuilder() + .setTitle('rush-sp') + .setVersion('1.0') + .addBearerAuth() + .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..c4f9086 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,20 @@ 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[] }; + expect(body.openapi).toMatch(/^3\./); + expect(body.servers ?? []).toHaveLength(0); + }); }); diff --git a/rush-user/package-lock.json b/rush-user/package-lock.json index 7f7cef2..d713c27 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", @@ -2163,49 +2170,6 @@ } } }, - "node_modules/@nestjs/cli/node_modules/webpack/node_modules/webpack": { - "version": "5.112.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.112.2.tgz", - "integrity": "sha512-B2tp1LI9D8GVpPDqprdt12ukTkXXahj9MzqSWJq4faSPgOUM7CdZaGAB6HrL/8E6UwbBpdj4vzWDbKoFnVoU6g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.26.0", - "es-module-lexer": "^2.1.0", - "events": "^3.2.0", - "graceful-fs": "^4.2.11", - "mime-db": "^1.54.0", - "minimizer-webpack-plugin": "^5.13.0", - "schema-utils": "^4.8.1", - "tapable": "^2.3.0", - "watchpack": "^2.6.3", - "webpack-sources": "^3.6.0" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, "node_modules/@nestjs/common": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.8.tgz", @@ -2276,6 +2240,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 +2385,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 +2802,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 +4161,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 +7293,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 +9039,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..63d1f5f --- /dev/null +++ b/rush-user/src/swagger.ts @@ -0,0 +1,19 @@ +import { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; + +/** + * OpenAPI 문서를 /user/v3/api-docs 에 냅니다. 게이트웨이 Swagger UI가 이 경로로 문서를 가져옵니다. + * servers를 비워 두면 Swagger UI가 문서를 받아 온 게이트웨이 주소로 요청을 보냅니다. + */ +export function setupSwagger(app: INestApplication): void { + const config = new DocumentBuilder() + .setTitle('rush-user') + .setVersion('1.0') + .addBearerAuth() + .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..41409db 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,20 @@ 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[] }; + expect(body.openapi).toMatch(/^3\./); + expect(body.servers ?? []).toHaveLength(0); + }); }); From 5af9fe78248f336a52aadc4946ee68c1e565f5d7 Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Tue, 3 Nov 2026 23:25:59 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(global):=20CI=EC=9D=98=20npm=20ci?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EC=B6=B0=20Nest=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20lock=20=ED=8C=8C=EC=9D=BC=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- rush-sp/package-lock.json | 43 +++++++++++++++++++++++++++++++++++++ rush-user/package-lock.json | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/rush-sp/package-lock.json b/rush-sp/package-lock.json index 039c797..1f40675 100644 --- a/rush-sp/package-lock.json +++ b/rush-sp/package-lock.json @@ -2170,6 +2170,49 @@ } } }, + "node_modules/@nestjs/cli/node_modules/webpack/node_modules/webpack": { + "version": "5.112.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.112.2.tgz", + "integrity": "sha512-B2tp1LI9D8GVpPDqprdt12ukTkXXahj9MzqSWJq4faSPgOUM7CdZaGAB6HrL/8E6UwbBpdj4vzWDbKoFnVoU6g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.26.0", + "es-module-lexer": "^2.1.0", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.13.0", + "schema-utils": "^4.8.1", + "tapable": "^2.3.0", + "watchpack": "^2.6.3", + "webpack-sources": "^3.6.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/@nestjs/common": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.8.tgz", diff --git a/rush-user/package-lock.json b/rush-user/package-lock.json index d713c27..1c5c347 100644 --- a/rush-user/package-lock.json +++ b/rush-user/package-lock.json @@ -2170,6 +2170,49 @@ } } }, + "node_modules/@nestjs/cli/node_modules/webpack/node_modules/webpack": { + "version": "5.112.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.112.2.tgz", + "integrity": "sha512-B2tp1LI9D8GVpPDqprdt12ukTkXXahj9MzqSWJq4faSPgOUM7CdZaGAB6HrL/8E6UwbBpdj4vzWDbKoFnVoU6g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.26.0", + "es-module-lexer": "^2.1.0", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.13.0", + "schema-utils": "^4.8.1", + "tapable": "^2.3.0", + "watchpack": "^2.6.3", + "webpack-sources": "^3.6.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, "node_modules/@nestjs/common": { "version": "11.2.8", "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.2.8.tgz", From dee026ada61becab6ac4096ed4550de1f86d6597 Mon Sep 17 00:00:00 2001 From: jyx-07 Date: Wed, 4 Nov 2026 08:21:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(global):=20Swagger=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20Bearer=20=EC=9D=B8=EC=A6=9D=20=EC=9A=94=EA=B5=AC?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rush/auth/config/OpenApiConfig.kt | 28 +++++++++++++++++++ .../com/rush/auth/config/OpenApiConfigTest.kt | 18 ++++++++++++ .../com/rush/minigame/config/OpenApiConfig.kt | 28 +++++++++++++++++++ .../rush/minigame/config/OpenApiConfigTest.kt | 18 ++++++++++++ .../com/rush/shop/config/OpenApiConfig.kt | 28 +++++++++++++++++++ .../com/rush/shop/config/OpenApiConfigTest.kt | 18 ++++++++++++ rush-sp/src/swagger.ts | 2 ++ rush-sp/test/app.e2e-spec.ts | 9 +++++- .../com/rush/stage/config/OpenApiConfig.kt | 28 +++++++++++++++++++ .../rush/stage/config/OpenApiConfigTest.kt | 18 ++++++++++++ rush-user/src/swagger.ts | 2 ++ rush-user/test/app.e2e-spec.ts | 9 +++++- 12 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 rush-auth/src/main/kotlin/com/rush/auth/config/OpenApiConfig.kt create mode 100644 rush-auth/src/test/kotlin/com/rush/auth/config/OpenApiConfigTest.kt create mode 100644 rush-minigame/src/main/kotlin/com/rush/minigame/config/OpenApiConfig.kt create mode 100644 rush-minigame/src/test/kotlin/com/rush/minigame/config/OpenApiConfigTest.kt create mode 100644 rush-shop/src/main/kotlin/com/rush/shop/config/OpenApiConfig.kt create mode 100644 rush-shop/src/test/kotlin/com/rush/shop/config/OpenApiConfigTest.kt create mode 100644 rush-stage/src/main/kotlin/com/rush/stage/config/OpenApiConfig.kt create mode 100644 rush-stage/src/test/kotlin/com/rush/stage/config/OpenApiConfigTest.kt 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-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/src/swagger.ts b/rush-sp/src/swagger.ts index 56c66c9..7e63c6d 100644 --- a/rush-sp/src/swagger.ts +++ b/rush-sp/src/swagger.ts @@ -4,12 +4,14 @@ 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, { diff --git a/rush-sp/test/app.e2e-spec.ts b/rush-sp/test/app.e2e-spec.ts index c4f9086..9125f0c 100644 --- a/rush-sp/test/app.e2e-spec.ts +++ b/rush-sp/test/app.e2e-spec.ts @@ -27,8 +27,15 @@ describe('AppController (e2e)', () => { const response = await request(app.getHttpServer()) .get('/sp/v3/api-docs') .expect(200); - const body = response.body as { openapi: string; servers?: unknown[] }; + 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/src/swagger.ts b/rush-user/src/swagger.ts index 63d1f5f..2cd75da 100644 --- a/rush-user/src/swagger.ts +++ b/rush-user/src/swagger.ts @@ -4,12 +4,14 @@ 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, { diff --git a/rush-user/test/app.e2e-spec.ts b/rush-user/test/app.e2e-spec.ts index 41409db..67ff2fb 100644 --- a/rush-user/test/app.e2e-spec.ts +++ b/rush-user/test/app.e2e-spec.ts @@ -27,8 +27,15 @@ describe('AppController (e2e)', () => { const response = await request(app.getHttpServer()) .get('/user/v3/api-docs') .expect(200); - const body = response.body as { openapi: string; servers?: unknown[] }; + 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: [] }); }); });