From 1211dadfdfcd8f01ca056996010897c215a63f50 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Tue, 30 Jun 2026 03:32:55 +0900 Subject: [PATCH 1/4] feat(api): serve swagger docs --- .env.example | 1 + apps/api/package.json | 7 +- apps/api/src/app.ts | 18 +++ apps/api/src/openapi.ts | 40 ++++++ apps/api/test/docs.routes.test.ts | 40 ++++++ docs/SWAGGER.md | 39 +++++- docs/openapi.yaml | 201 ++++++++++++++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 62 +++++++-- 9 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 apps/api/src/openapi.ts create mode 100644 apps/api/test/docs.routes.test.ts diff --git a/.env.example b/.env.example index 3a344ad..2620176 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/DB_NAME" WEB_APP_ORIGIN="http://localhost:3000" +API_PUBLIC_BASE_URL="http://localhost:3001/api" JWT_ACCESS_SECRET="replace-with-local-access-secret" JWT_ACCESS_EXPIRES_IN="15m" REFRESH_TOKEN_EXPIRES_DAYS="7" diff --git a/apps/api/package.json b/apps/api/package.json index fb47d07..1f38613 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -5,12 +5,12 @@ "scripts": { "start:dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts", "dev": "pnpm --filter @fragment/shared build && node --env-file=../../.env --watch -r ts-node/register -r tsconfig-paths/register src/main.ts", - "build": "pnpm --filter @fragment/shared build && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "start": "node dist/main", "lint": "eslint \"{src,test}/**/*.ts\"", "test": "jest --runInBand", "test:watch": "jest --watch", - "typecheck": "pnpm --filter @fragment/shared build && tsc --noEmit" + "typecheck": "tsc --noEmit" }, "dependencies": { "@fragment/database": "workspace:*", @@ -20,6 +20,8 @@ "cors": "^2.8.5", "express": "^5.1.0", "jsonwebtoken": "^9.0.3", + "swagger-ui-express": "^5.0.1", + "yaml": "^2.9.0", "zod": "^3.25.76" }, "devDependencies": { @@ -30,6 +32,7 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20", "@types/supertest": "^6.0.3", + "@types/swagger-ui-express": "^4.1.8", "eslint": "^9", "jest": "^29.7.0", "supertest": "^7.2.2", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f2cb9ef..ca53352 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,11 +1,13 @@ import cors from "cors"; import cookieParser from "cookie-parser"; import express from "express"; +import swaggerUi from "swagger-ui-express"; import type { PrismaClient } from "@fragment/database"; import { ERROR_CODES } from "@/common/constants/error-codes"; import { HttpError } from "@/errors/http-error"; import { errorHandler } from "@/middlewares/error-handler"; +import { getOpenApiDocument } from "@/openapi"; import { apiRoutes } from "@/routes"; type AppDependencies = { @@ -31,6 +33,22 @@ export const createApp = ({ prisma }: AppDependencies) => { app.use(express.json()); app.use(cookieParser()); + const openApiDocument = getOpenApiDocument(); + + app.get("/api/openapi.json", (_req, res) => { + res.json(openApiDocument); + }); + app.use( + "/api/docs", + swaggerUi.serve, + swaggerUi.setup(openApiDocument, { + customSiteTitle: "프래그먼트 API Docs", + swaggerOptions: { + persistAuthorization: true, + }, + }), + ); + app.use("/api", apiRoutes); app.use((_req, _res, next) => { diff --git a/apps/api/src/openapi.ts b/apps/api/src/openapi.ts new file mode 100644 index 0000000..5180060 --- /dev/null +++ b/apps/api/src/openapi.ts @@ -0,0 +1,40 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { parse } from "yaml"; + +type OpenApiServer = { + description?: string; + url: string; +}; + +export type OpenApiDocument = { + servers?: OpenApiServer[]; + [key: string]: unknown; +}; + +const OPENAPI_DOCUMENT_PATH = resolve(__dirname, "../../../docs/openapi.yaml"); + +const trimTrailingSlash = (value: string) => value.replace(/\/+$/, ""); + +export const getOpenApiDocument = (): OpenApiDocument => { + const document = parse(readFileSync(OPENAPI_DOCUMENT_PATH, "utf8")) as OpenApiDocument; + const publicBaseUrl = process.env.API_PUBLIC_BASE_URL?.trim(); + + if (!publicBaseUrl) { + return document; + } + + const normalizedPublicBaseUrl = trimTrailingSlash(publicBaseUrl); + const existingServers = document.servers ?? []; + + return { + ...document, + servers: [ + { + url: normalizedPublicBaseUrl, + description: "Configured deployment", + }, + ...existingServers.filter((server) => server.url !== normalizedPublicBaseUrl), + ], + }; +}; diff --git a/apps/api/test/docs.routes.test.ts b/apps/api/test/docs.routes.test.ts new file mode 100644 index 0000000..a8a744c --- /dev/null +++ b/apps/api/test/docs.routes.test.ts @@ -0,0 +1,40 @@ +import { beforeEach, describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { createApp } from "@/app"; +import { createFakePrisma } from "./helpers/fake-prisma"; + +describe("docs routes", () => { + beforeEach(() => { + delete process.env.API_PUBLIC_BASE_URL; + delete process.env.WEB_APP_ORIGIN; + }); + + it("serves Swagger UI", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/docs/"); + + expect(response.status).toBe(200); + expect(response.headers["content-type"]).toContain("text/html"); + expect(response.text).toContain("swagger-ui"); + }); + + it("serves OpenAPI JSON with the configured public server first", async () => { + process.env.API_PUBLIC_BASE_URL = "https://api.example.com/api"; + + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/openapi.json"); + + expect(response.status).toBe(200); + expect(response.body.openapi).toBe("3.1.0"); + expect(response.body.servers[0]).toEqual({ + url: "https://api.example.com/api", + description: "Configured deployment", + }); + expect(response.body.paths["/staffing-rules"]).toBeDefined(); + }); +}); diff --git a/docs/SWAGGER.md b/docs/SWAGGER.md index c0174d6..00b8a3e 100644 --- a/docs/SWAGGER.md +++ b/docs/SWAGGER.md @@ -10,7 +10,7 @@ ## 현재 OpenAPI 문서화 범위 -현재 `docs/openapi.yaml`은 아래 범위를 문서화합니다. +현재 `docs/openapi.yaml`은 구현된 API 중 아래 범위를 문서화합니다. - 공통 metadata - 공통 security scheme @@ -30,6 +30,30 @@ - `POST /organization` - `GET /organization` - `PATCH /organization` +- Active schedule planning period endpoint + - `GET /organization/active-schedule-planning-period` + - `PUT /organization/active-schedule-planning-period` + - `DELETE /organization/active-schedule-planning-period` +- Availability endpoint + - `GET /availability` + - `PUT /availability/bulk` +- Workers endpoint + - `GET /workers` + - `POST /workers` + - `PATCH /workers/{workerId}` + - `DELETE /workers/{workerId}` +- Staffing Rules endpoint + - `GET /staffing-rules` + - `POST /staffing-rules` + - `PATCH /staffing-rules/{ruleId}` + - `DELETE /staffing-rules/{ruleId}` +- Schedules endpoint + - `POST /schedules/recommend` + - `GET /schedules/draft` + - `POST /schedules/{scheduleId}/assignments` + - `PATCH /schedules/{scheduleId}/assignments/{assignmentId}` + - `DELETE /schedules/{scheduleId}/assignments/{assignmentId}` + - `POST /schedules/{scheduleId}/confirm` ## 변경 기준 @@ -44,11 +68,22 @@ Swagger UI를 API 서버에서 제공하는 작업을 진행할 경우 아래 기준을 따릅니다. - Swagger UI 라우트는 `/api/docs`로 제공합니다. +- 원본 OpenAPI JSON은 `/api/openapi.json`로 제공합니다. - OpenAPI `servers[0].url`은 local 개발 기준 `http://localhost:3001/api`로 시작합니다. -- 운영 배포 후 production server URL을 추가합니다. +- 운영 배포에서는 `API_PUBLIC_BASE_URL=https:///api`를 설정해 production server URL을 첫 번째 server로 제공합니다. - `docs/openapi.yaml`이 YAML로 파싱되어야 합니다. - Swagger UI에 `docs/openapi.yaml`의 endpoint와 공통 schema가 표시되어야 합니다. +## 외부 공유 기준 + +외부 공유용 Swagger는 아래 상태를 만족한 후 공유합니다. + +- 공유 URL은 `https:///api/docs`입니다. +- `https:///api/openapi.json`에서 원본 OpenAPI 문서를 내려받을 수 있어야 합니다. +- Swagger UI의 server URL이 `localhost`가 아니라 운영 API URL이어야 합니다. +- 실제 구현되지 않은 endpoint는 OpenAPI에 포함하지 않습니다. +- 인증이 필요한 API는 `Authorize` 버튼에 로그인 API에서 받은 bearer token을 넣어 테스트합니다. + ## Swagger UI 도입 시 추가 패키지 Swagger UI를 API 서버에서 제공하는 작업을 진행할 때 `apps/api`에 아래 패키지를 추가합니다. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1b1959c..b561488 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -441,6 +441,108 @@ paths: "404": $ref: "#/components/responses/NotFound" + /staffing-rules: + get: + tags: + - StaffingRules + summary: 최소 인원 조건 목록 조회 + operationId: listMinimumStaffingRules + security: + - bearerAuth: [] + responses: + "200": + description: 최소 인원 조건 목록 조회 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/MinimumStaffingRulesListResponse" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + + post: + tags: + - StaffingRules + summary: 최소 인원 조건 생성 + description: 최소 인원 조건은 해당 요일의 조직 운영시간 안에서만 등록할 수 있습니다. + operationId: createMinimumStaffingRule + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMinimumStaffingRuleRequest" + responses: + "201": + description: 최소 인원 조건 생성 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/MinimumStaffingRule" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + + /staffing-rules/{ruleId}: + patch: + tags: + - StaffingRules + summary: 최소 인원 조건 수정 + description: 수정 후의 조건도 해당 요일의 조직 운영시간 안에 있어야 합니다. + operationId: updateMinimumStaffingRule + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/RuleId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMinimumStaffingRuleRequest" + responses: + "200": + description: 최소 인원 조건 수정 성공 + content: + application/json: + schema: + $ref: "#/components/schemas/MinimumStaffingRule" + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + "404": + $ref: "#/components/responses/NotFound" + + delete: + tags: + - StaffingRules + summary: 최소 인원 조건 삭제 + operationId: deleteMinimumStaffingRule + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/RuleId" + responses: + "204": + description: 최소 인원 조건 삭제 성공 + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/OrganizationRequired" + "404": + $ref: "#/components/responses/NotFound" + /schedules/recommend: post: tags: @@ -684,6 +786,14 @@ components: type: string pattern: "^\\d+$" example: "1" + RuleId: + name: ruleId + in: path + required: true + schema: + type: string + pattern: "^\\d+$" + example: "1" AvailabilityWorkerId: name: workerId in: query @@ -1159,6 +1269,97 @@ components: minimum: 1 example: 32 + MinimumStaffingRule: + type: object + required: + - id + - dayOfWeek + - startTime + - endTime + - endsNextDay + - requiredCount + properties: + id: + type: string + pattern: "^\\d+$" + example: "1" + dayOfWeek: + $ref: "#/components/schemas/DayOfWeek" + startTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "10:00" + endTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "14:00" + endsNextDay: + type: boolean + example: false + requiredCount: + type: integer + minimum: 1 + example: 2 + + MinimumStaffingRulesListResponse: + type: object + required: + - items + properties: + items: + type: array + items: + $ref: "#/components/schemas/MinimumStaffingRule" + + CreateMinimumStaffingRuleRequest: + type: object + required: + - dayOfWeek + - startTime + - endTime + - endsNextDay + - requiredCount + properties: + dayOfWeek: + $ref: "#/components/schemas/DayOfWeek" + startTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "10:00" + endTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "14:00" + endsNextDay: + type: boolean + example: false + requiredCount: + type: integer + minimum: 1 + example: 2 + + UpdateMinimumStaffingRuleRequest: + type: object + minProperties: 1 + properties: + dayOfWeek: + $ref: "#/components/schemas/DayOfWeek" + startTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "10:00" + endTime: + type: string + pattern: "^\\d{2}:\\d{2}$" + example: "14:00" + endsNextDay: + type: boolean + example: false + requiredCount: + type: integer + minimum: 1 + example: 3 + DateRangeRequest: type: object required: diff --git a/package.json b/package.json index be3bca6..aec3b77 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "dev:api": "pnpm --filter @fragment/database build && pnpm --filter @fragment/api dev", "dev:all": "pnpm --filter @fragment/database build && pnpm --parallel --filter @fragment/web --filter @fragment/api dev", "build": "pnpm --filter @fragment/web build", - "build:api": "pnpm --filter @fragment/api build", + "build:api": "corepack pnpm --filter @fragment/api... build", "db:generate": "pnpm --filter @fragment/database db:generate", "db:migrate": "pnpm --filter @fragment/database db:migrate", "db:push": "pnpm --filter @fragment/database db:push", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d20cab..c183621 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,12 @@ importers: jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 + swagger-ui-express: + specifier: ^5.0.1 + version: 5.0.1(express@5.2.1) + yaml: + specifier: ^2.9.0 + version: 2.9.0 zod: specifier: ^3.25.76 version: 3.25.76 @@ -69,6 +75,9 @@ importers: '@types/supertest': specifier: ^6.0.3 version: 6.0.3 + '@types/swagger-ui-express': + specifier: ^4.1.8 + version: 4.1.8 eslint: specifier: ^9 version: 9.39.4(jiti@2.7.0) @@ -198,7 +207,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)) + version: 4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) packages/database: dependencies: @@ -1597,6 +1606,9 @@ packages: '@rushstack/eslint-patch@1.16.1': resolution: {integrity: sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==} + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@simple-libs/child-process-utils@1.0.2': resolution: {integrity: sha512-/4R8QKnd/8agJynkNdJmNw2MBxuFTRcNFnE5Sg/G+jkSsV8/UBgULMzhizWWW42p8L5H7flImV2ATi79Ove2Tw==} engines: {node: '>=18'} @@ -1885,6 +1897,9 @@ packages: '@types/supertest@6.0.3': resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==} + '@types/swagger-ui-express@4.1.8': + resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -4527,6 +4542,15 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swagger-ui-dist@5.32.8: + resolution: {integrity: sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==} + + swagger-ui-express@5.0.1: + resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==} + engines: {node: '>= v0.10.32'} + peerDependencies: + express: '>=4.0.0 || >=5.0.0-beta' + symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} @@ -5008,6 +5032,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -6431,6 +6460,8 @@ snapshots: '@rushstack/eslint-patch@1.16.1': {} + '@scarf/scarf@1.4.0': {} + '@simple-libs/child-process-utils@1.0.2': dependencies: '@simple-libs/stream-utils': 1.2.0 @@ -6728,6 +6759,11 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/swagger-ui-express@4.1.8': + dependencies: + '@types/express': 5.0.6 + '@types/serve-static': 2.2.0 + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.35': @@ -6904,13 +6940,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0) + vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -9806,6 +9842,15 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swagger-ui-dist@5.32.8: + dependencies: + '@scarf/scarf': 1.4.0 + + swagger-ui-express@5.0.1(express@5.2.1): + dependencies: + express: 5.2.1 + swagger-ui-dist: 5.32.8 + symbol-tree@3.2.4: {} tailwind-merge@3.6.0: {} @@ -10089,7 +10134,7 @@ snapshots: vary@1.1.2: {} - vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0): + vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -10101,11 +10146,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 terser: 5.48.0 + yaml: 2.9.0 - vitest@4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)): + vitest@4.1.9(@types/node@20.19.41)(jsdom@29.1.1(@noble/hashes@1.8.0))(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -10122,7 +10168,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0) + vite: 8.1.0(@types/node@20.19.41)(jiti@2.7.0)(terser@5.48.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 20.19.41 @@ -10279,6 +10325,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} yargs-parser@22.0.0: {} From 90c2e74e8c6d6ed9d30ac2bd1cdb89a4b04a2c77 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Tue, 30 Jun 2026 03:49:03 +0900 Subject: [PATCH 2/4] fix(api): address swagger docs review --- apps/api/src/app.ts | 3 --- apps/api/test/docs.routes.test.ts | 18 ++++++++++++++++++ docs/openapi.yaml | 29 +++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ca53352..2968949 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -43,9 +43,6 @@ export const createApp = ({ prisma }: AppDependencies) => { swaggerUi.serve, swaggerUi.setup(openApiDocument, { customSiteTitle: "프래그먼트 API Docs", - swaggerOptions: { - persistAuthorization: true, - }, }), ); diff --git a/apps/api/test/docs.routes.test.ts b/apps/api/test/docs.routes.test.ts index a8a744c..e0bd30a 100644 --- a/apps/api/test/docs.routes.test.ts +++ b/apps/api/test/docs.routes.test.ts @@ -21,6 +21,16 @@ describe("docs routes", () => { expect(response.text).toContain("swagger-ui"); }); + it("does not persist Swagger UI authorization in browser storage", async () => { + const { prisma } = createFakePrisma(); + const app = createApp({ prisma }); + + const response = await request(app).get("/api/docs/swagger-ui-init.js"); + + expect(response.status).toBe(200); + expect(response.text).not.toContain("persistAuthorization"); + }); + it("serves OpenAPI JSON with the configured public server first", async () => { process.env.API_PUBLIC_BASE_URL = "https://api.example.com/api"; @@ -36,5 +46,13 @@ describe("docs routes", () => { description: "Configured deployment", }); expect(response.body.paths["/staffing-rules"]).toBeDefined(); + expect( + response.body.components.responses.MinimumStaffingRuleBadRequest.content["application/json"] + .examples.closedDay.value.errorCode, + ).toBe("CLOSED_DAY"); + expect( + response.body.components.responses.MinimumStaffingRuleBadRequest.content["application/json"] + .examples.invalidTimeRange.value.errorCode, + ).toBe("INVALID_TIME_RANGE"); }); }); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index b561488..ea6f5d6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -483,7 +483,7 @@ paths: schema: $ref: "#/components/schemas/MinimumStaffingRule" "400": - $ref: "#/components/responses/ValidationError" + $ref: "#/components/responses/MinimumStaffingRuleBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -514,7 +514,7 @@ paths: schema: $ref: "#/components/schemas/MinimumStaffingRule" "400": - $ref: "#/components/responses/ValidationError" + $ref: "#/components/responses/MinimumStaffingRuleBadRequest" "401": $ref: "#/components/responses/Unauthorized" "403": @@ -758,6 +758,31 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + MinimumStaffingRuleBadRequest: + description: 요청 값 검증 실패 또는 최소 인원 조건 도메인 규칙 위반 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + validationError: + summary: 요청 형식 오류 + value: + statusCode: 400 + errorCode: VALIDATION_ERROR + message: 요청 형식이 올바르지 않습니다. + closedDay: + summary: 휴무일 요청 + value: + statusCode: 400 + errorCode: CLOSED_DAY + message: 휴무일에는 최소 인원 조건을 등록할 수 없습니다. + invalidTimeRange: + summary: 운영시간 밖 요청 + value: + statusCode: 400 + errorCode: INVALID_TIME_RANGE + message: 최소 인원 조건은 조직 운영시간 안에서만 등록할 수 있습니다. Unauthorized: description: 인증 실패 content: From 49ee4c151da349983c497bae9874458cfcf35bcb Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Tue, 30 Jun 2026 04:14:10 +0900 Subject: [PATCH 3/4] fix(api): document error response examples --- apps/api/test/docs.routes.test.ts | 52 ++++++++++++ docs/openapi.yaml | 134 ++++++++++++++++++++++-------- 2 files changed, 151 insertions(+), 35 deletions(-) diff --git a/apps/api/test/docs.routes.test.ts b/apps/api/test/docs.routes.test.ts index e0bd30a..cf675c8 100644 --- a/apps/api/test/docs.routes.test.ts +++ b/apps/api/test/docs.routes.test.ts @@ -4,6 +4,47 @@ import request from "supertest"; import { createApp } from "@/app"; import { createFakePrisma } from "./helpers/fake-prisma"; +const resolveOpenApiRef = (document: any, ref: string) => + ref + .slice(2) + .split("/") + .reduce((value: any, key) => value?.[key], document); + +const expectResponseExamplesToMatchStatusCodes = (document: any) => { + for (const pathItem of Object.values(document.paths)) { + for (const operation of Object.values(pathItem)) { + const responses = operation.responses; + + if (!responses) { + continue; + } + + for (const [statusCode, responseDefinition] of Object.entries(responses)) { + const response = + typeof responseDefinition.$ref === "string" + ? resolveOpenApiRef(document, responseDefinition.$ref) + : responseDefinition; + const jsonContent = response?.content?.["application/json"]; + const expectedStatusCode = Number(statusCode); + + if (!Number.isInteger(expectedStatusCode) || !jsonContent) { + continue; + } + + if (jsonContent.example?.statusCode !== undefined) { + expect(jsonContent.example.statusCode).toBe(expectedStatusCode); + } + + for (const example of Object.values(jsonContent.examples ?? {})) { + if (example.value?.statusCode !== undefined) { + expect(example.value.statusCode).toBe(expectedStatusCode); + } + } + } + } + } +}; + describe("docs routes", () => { beforeEach(() => { delete process.env.API_PUBLIC_BASE_URL; @@ -54,5 +95,16 @@ describe("docs routes", () => { response.body.components.responses.MinimumStaffingRuleBadRequest.content["application/json"] .examples.invalidTimeRange.value.errorCode, ).toBe("INVALID_TIME_RANGE"); + expect( + response.body.components.responses.DuplicateEmail.content["application/json"].example, + ).toEqual({ + statusCode: 409, + errorCode: "DUPLICATE_EMAIL", + message: "이미 사용 중인 이메일입니다.", + }); + expect(response.body.paths["/auth/signup"].post.responses["409"].$ref).toBe( + "#/components/responses/DuplicateEmail", + ); + expectResponseExamplesToMatchStatusCodes(response.body); }); }); diff --git a/docs/openapi.yaml b/docs/openapi.yaml index ea6f5d6..4cb8574 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -51,11 +51,7 @@ paths: "400": $ref: "#/components/responses/ValidationError" "409": - description: 이미 가입된 이메일 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/DuplicateEmail" /auth/login: post: @@ -162,11 +158,7 @@ paths: "401": $ref: "#/components/responses/Unauthorized" "409": - description: 이미 조직이 존재함 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/OrganizationAlreadyExists" get: tags: @@ -572,11 +564,7 @@ paths: "403": $ref: "#/components/responses/OrganizationRequired" "409": - description: 같은 입력 조건의 DRAFT가 이미 존재함 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/DraftAlreadyExists" /schedules/draft: get: @@ -635,11 +623,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" /schedules/{scheduleId}/assignments/{assignmentId}: patch: @@ -674,11 +658,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" delete: tags: @@ -702,11 +682,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" /schedules/{scheduleId}/confirm: post: @@ -734,11 +710,7 @@ paths: "404": $ref: "#/components/responses/NotFound" "409": - description: DRAFT 상태가 아닌 스케줄 - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" + $ref: "#/components/responses/ScheduleConflict" components: securitySchemes: @@ -758,6 +730,10 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 400 + errorCode: VALIDATION_ERROR + message: 요청 형식이 올바르지 않습니다. MinimumStaffingRuleBadRequest: description: 요청 값 검증 실패 또는 최소 인원 조건 도메인 규칙 위반 content: @@ -789,18 +765,106 @@ components: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + examples: + invalidCredentials: + summary: 로그인 인증 실패 + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: 이메일 또는 비밀번호가 올바르지 않습니다. + authRequired: + summary: 인증 필요 + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: 인증이 필요합니다. + invalidToken: + summary: 유효하지 않은 인증 토큰 + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: 유효하지 않은 인증 토큰입니다. + invalidRefreshToken: + summary: 유효하지 않은 refresh token + value: + statusCode: 401 + errorCode: UNAUTHORIZED + message: Refresh token이 유효하지 않습니다. NotFound: description: 리소스를 찾을 수 없음 content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + examples: + notFound: + summary: 일반 리소스 없음 + value: + statusCode: 404 + errorCode: NOT_FOUND + message: 요청한 API를 찾을 수 없습니다. + workerNotFound: + summary: 근무자 없음 + value: + statusCode: 404 + errorCode: WORKER_NOT_FOUND + message: 근무자를 찾을 수 없습니다. + scheduleNotFound: + summary: 스케줄 없음 + value: + statusCode: 404 + errorCode: SCHEDULE_NOT_FOUND + message: 스케줄을 찾을 수 없습니다. OrganizationRequired: description: 조직 생성 필요 content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 403 + errorCode: ORGANIZATION_REQUIRED + message: 조직 생성 후 이용할 수 있습니다. + DuplicateEmail: + description: 이미 가입된 이메일 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: DUPLICATE_EMAIL + message: 이미 사용 중인 이메일입니다. + OrganizationAlreadyExists: + description: 이미 조직이 존재함 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: ORGANIZATION_ALREADY_EXISTS + message: 이미 조직이 존재합니다. + DraftAlreadyExists: + description: 같은 입력 조건의 DRAFT가 이미 존재함 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: DRAFT_ALREADY_EXISTS + message: 이미 생성된 DRAFT 스케줄입니다. + ScheduleConflict: + description: DRAFT 상태가 아닌 스케줄 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + statusCode: 409 + errorCode: CONFLICT + message: DRAFT 상태의 스케줄만 변경할 수 있습니다. parameters: WorkerId: From ddbc50a4283769a481824bedb31065d889b0114c Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Tue, 30 Jun 2026 04:52:12 +0900 Subject: [PATCH 4/4] test(api): assert openapi response refs resolve --- apps/api/test/docs.routes.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/api/test/docs.routes.test.ts b/apps/api/test/docs.routes.test.ts index cf675c8..5371c4a 100644 --- a/apps/api/test/docs.routes.test.ts +++ b/apps/api/test/docs.routes.test.ts @@ -24,6 +24,11 @@ const expectResponseExamplesToMatchStatusCodes = (document: any) => { typeof responseDefinition.$ref === "string" ? resolveOpenApiRef(document, responseDefinition.$ref) : responseDefinition; + + if (typeof responseDefinition.$ref === "string") { + expect(response).toBeDefined(); + } + const jsonContent = response?.content?.["application/json"]; const expectedStatusCode = Number(statusCode);