Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
7 changes: 5 additions & 2 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand All @@ -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": {
Expand All @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -31,6 +33,19 @@ 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",
}),
);

app.use("/api", apiRoutes);

app.use((_req, _res, next) => {
Expand Down
40 changes: 40 additions & 0 deletions apps/api/src/openapi.ts
Original file line number Diff line number Diff line change
@@ -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),
],
};
};
115 changes: 115 additions & 0 deletions apps/api/test/docs.routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
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<any>(document.paths)) {
for (const operation of Object.values<any>(pathItem)) {
const responses = operation.responses;

if (!responses) {
continue;
}

for (const [statusCode, responseDefinition] of Object.entries<any>(responses)) {
const response =
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);

if (!Number.isInteger(expectedStatusCode) || !jsonContent) {
continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (jsonContent.example?.statusCode !== undefined) {
expect(jsonContent.example.statusCode).toBe(expectedStatusCode);
}

for (const example of Object.values<any>(jsonContent.examples ?? {})) {
if (example.value?.statusCode !== undefined) {
expect(example.value.statusCode).toBe(expectedStatusCode);
}
}
}
}
}
};

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("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";

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();
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");
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);
});
});
39 changes: 37 additions & 2 deletions docs/SWAGGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

## 현재 OpenAPI 문서화 범위

현재 `docs/openapi.yaml`은 아래 범위를 문서화합니다.
현재 `docs/openapi.yaml`은 구현된 API 중 아래 범위를 문서화합니다.

- 공통 metadata
- 공통 security scheme
Expand All @@ -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`

## 변경 기준

Expand All @@ -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-domain>/api`를 설정해 production server URL을 첫 번째 server로 제공합니다.
- `docs/openapi.yaml`이 YAML로 파싱되어야 합니다.
- Swagger UI에 `docs/openapi.yaml`의 endpoint와 공통 schema가 표시되어야 합니다.

## 외부 공유 기준

외부 공유용 Swagger는 아래 상태를 만족한 후 공유합니다.

- 공유 URL은 `https://<api-domain>/api/docs`입니다.
- `https://<api-domain>/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`에 아래 패키지를 추가합니다.
Expand Down
Loading
Loading