diff --git a/.github/release-please/manifest.json b/.github/release-please/manifest.json index 8efb275a..0bbe194f 100644 --- a/.github/release-please/manifest.json +++ b/.github/release-please/manifest.json @@ -1 +1 @@ -{".":"0.4.0"} +{".":"0.4.1"} diff --git a/.npmrc b/.npmrc index 16ec864b..e0c96c04 100644 --- a/.npmrc +++ b/.npmrc @@ -1,34 +1,9 @@ -# package.json의 engines 필드를 엄격하게 검사 -# node 버전이나 package manager 버전이 맞지 않으면 설치 불가 engine-strict=true - -# peer dependencies를 자동으로 설치 -# 예: React 라이브러리가 'react'를 peer dependency로 가질 때 자동 설치 auto-install-peers=true - -# peer dependencies 버전 충돌 시 경고만 하고 설치 진행 -# false: 충돌이 있어도 설치 진행 -# true: 충돌 시 설치 중단 strict-peer-dependencies=false - -# workspace 패키지 간 의존성 버전 관리 방식 -# rolling: 항상 최신 버전 사용 -# fixed: 명시된 버전 사용 save-workspace-protocol=rolling - -# 추가 권장 설정들: - -# node_modules 위치 지정 (프로젝트 루트에 고정) shamefully-hoist=true - -# 패키지 잠금 파일 생성 (pnpm-lock.yaml) lockfile=true - -# 패키지 설치 시 누락된 peer dependencies 경고 strict-peer-dependencies=false - -# 프로덕션 환경에서 devDependencies 설치 방지 production=false - -# 패키지 설치 시 진행 상황 자세히 표시 -loglevel=verbose \ No newline at end of file +loglevel=verbose diff --git a/.vscode/settings.json b/.vscode/settings.json index 19f996ff..39145ef7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,16 +1,11 @@ { - // 파일 저장 시 자동 포맷팅 "editor.formatOnSave": true, - - // TypeScript/JavaScript 파일에 대한 기본 포맷터로 Prettier 사용 "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - - // Markdown 및 MDX 파일에 대한 ESLint 및 Prettier 비활성화 "[markdown]": { "editor.formatOnSave": false, "editor.codeActionsOnSave": { @@ -23,21 +18,13 @@ "source.fixAll.eslint": "never" } }, - - // ESLint 설정 "eslint.enable": true, "eslint.validate": ["javascript", "typescript"], - - // 파일 저장 시 ESLint 자동 수정 (Markdown/MDX 제외) "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, - - // Prettier 설정 "prettier.requireConfig": true, "prettier.configPath": ".prettierrc", "prettier.disableLanguages": ["markdown", "mdx"], - - // TypeScript 설정 "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e098b59..bef5930f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.4.1](https://github.com/do-pa/itdoc/compare/v0.4.0...v0.4.1) (2025-08-21) + + +### 🩹 Fixes + +* og_image is not working ([#231](https://github.com/do-pa/itdoc/issues/231)) ([d1070ec](https://github.com/do-pa/itdoc/commit/d1070ec1064cf914b576e2eb9153378903ccaf89)), closes [#230](https://github.com/do-pa/itdoc/issues/230) +* resolve issue causing LLM script to not run properly ([#235](https://github.com/do-pa/itdoc/issues/235)) ([1fa8d90](https://github.com/do-pa/itdoc/commit/1fa8d90fe14c37031baaa58f375550785f055275)) + ## [0.4.0](https://github.com/do-pa/itdoc/compare/v0.3.0...v0.4.0) (2025-08-08) diff --git a/bin/index.ts b/bin/index.ts index e55edac9..79f1e8d4 100644 --- a/bin/index.ts +++ b/bin/index.ts @@ -43,7 +43,7 @@ const program = new Command() program .name("itdoc") - .usage("A CLI tool for generating ITDOC test code using LLM") // ✅ 직접 Usage 설정 + .usage("A CLI tool for generating ITDOC test code using LLM") .addHelpText( "after", ` @@ -55,22 +55,19 @@ Example: program .command("generate") .description("Generate ITDOC test code based on LLM.") - .option("-p, --path ", "Path to the markdown test spec file.") .option("-a, --app ", "Path to the Express root app file.") .option("-e, --env ", "Path to the .env file.") - .action((options: { path?: string; env?: string; app?: string }) => { + .action(async (options: { env?: string; app?: string }) => { const envPath = options.env ? path.isAbsolute(options.env) ? options.env : path.resolve(process.cwd(), options.env) : path.resolve(process.cwd(), ".env") - if (!options.path && !options.app) { + if (!options.app) { logger.error( - "Either a test spec path (-p) or an Express app path (-a) must be provided. By default, the OpenAI key (OPENAI_API_KEY in .env) is loaded from the root directory, but you can customize the path if needed", + "An Express app path (-a) must be provided. By default, the OpenAI key (OPENAI_API_KEY in .env) is loaded from the root directory, but you can customize the path with -e/--env if needed.", ) - logger.info("ex) itdoc generate -p ../md/testspec.md") - logger.info("ex) itdoc generate --path ../md/testspec.md") logger.info("ex) itdoc generate -a ../app.js") logger.info("ex) itdoc generate --app ../app.js") logger.info("ex) itdoc generate -a ../app.js -e ") @@ -80,12 +77,14 @@ program logger.box("ITDOC LLM START") if (options.app) { const appPath = resolvePath(options.app) + logger.info(`Running analysis based on Express app path: ${appPath}`) - generateByLLM("", appPath, envPath) - } else if (options.path) { - const specPath = resolvePath(options.path) - logger.info(`Running analysis based on test spec (MD) path: ${specPath}`) - generateByLLM(specPath, "", envPath) + try { + await generateByLLM(appPath, envPath) + } catch (err) { + logger.error(`LLM generation failed: ${(err as Error).message}`) + process.exit(1) + } } }) diff --git a/eslint.config.js b/eslint.config.js index 2e6effec..ebf4d5ad 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -19,13 +19,13 @@ export default tseslint.config( "output/**", ], }, - // ESLint 기본 추천 규칙 + // Apply the default ESLint recommended rules. eslint.configs.recommended, - // TypeScript 추천 규칙 + // Apply the TypeScript recommended rules. ...tseslint.configs.recommended, - // JSDoc 문서화 추천 규칙 + // Apply the recommended JSDoc documentation rules. jsdoc.configs["flat/recommended"], - // Mocha 플러그인 설정 + // Configure the Mocha plugin. { plugins: { mocha: mochaPlugin, @@ -34,17 +34,17 @@ export default tseslint.config( { files: ["**/*.ts"], languageOptions: { - // 사용할 JavaScript 버전 지정 + // Target the specified JavaScript version. ecmaVersion: 2022, - // ESM 모듈 시스템 사용 + // Use the ESM module system. sourceType: "module", - // TypeScript 파서 사용 + // Use the TypeScript parser. parser: tseslint.parser, parserOptions: { - // TypeScript 설정 파일 지정 + // Reference the TypeScript project configuration files. project: ["./tsconfig.json", "./tsconfig.test.json"], }, - // env 대신 globals 사용 + // Provide globals instead of using env shortcuts. globals: { ...globals.node, ...globals.es2022, @@ -56,45 +56,45 @@ export default tseslint.config( "license-header": licenseHeader, }, rules: { - // TypeScript 관련 규칙 - // 함수의 반환 타입 명시 필수 -> widdershins 모듈문제로 임시 비활성화 + // Configure TypeScript-specific rules. + // Require explicit return types, temporarily disabled due to the widdershins module. "@typescript-eslint/explicit-function-return-type": "off", - // 사용하지 않는 변수 에러 처리 (_로 시작하는 변수는 제외) - "@typescript-eslint/no-explicit-any": "warn", // error에서 warn으로 변경 + // Flag unused variables while allowing names that start with an underscore. + "@typescript-eslint/no-explicit-any": "warn", // Set severity to warn instead of error. "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - // 클래스 멤버의 접근 제한자 명시 필수 + // Require explicit access modifiers on class members. "@typescript-eslint/explicit-member-accessibility": [ "error", { accessibility: "explicit" }, ], - "@typescript-eslint/no-require-imports": "warn", // error에서 warn으로 변경 + "@typescript-eslint/no-require-imports": "warn", // Set severity to warn instead of error. - // JSDoc 규칙 완화 + // Relax certain JSDoc rules. "jsdoc/require-description": "warn", "jsdoc/require-param-description": "warn", "jsdoc/require-returns-description": "warn", - "jsdoc/require-example": "off", // 필수 예제 비활성화 - "jsdoc/check-examples": "off", // 예제 검사 비활성화 + "jsdoc/require-example": "off", // Disable mandatory examples. + "jsdoc/check-examples": "off", // Disable example validation. "jsdoc/require-throws": "warn", "jsdoc/require-param": "warn", "jsdoc/require-returns": "warn", "jsdoc/require-param-type": "warn", - // 코드 품질 규칙 - // 중첩 콜백 최대 3개까지 허용 + // Configure code-quality rules. + // Allow up to three nested callbacks. "max-nested-callbacks": ["error", 3], - // 함수당 최대 50줄 제한 (빈 줄과 주석 제외) + // Warn when a function exceeds 150 lines excluding blanks and comments. "max-lines-per-function": [ - "warn", // error에서 warn으로 변경 + "warn", // Set severity to warn instead of error. { max: 150, skipBlankLines: true, skipComments: true }, ], - // Mocha 테스트 규칙 + // Configure Mocha test rules. "mocha/no-skipped-tests": "warn", - // 단독 실행 테스트 금지 + // Forbid exclusive tests. "mocha/no-exclusive-tests": "error", - // 라이센스 헤더 규칙 + // Enforce the license header rule. "license-header/header": [ "error", [ @@ -116,14 +116,14 @@ export default tseslint.config( ], ], - // no-console 규칙: widdershins 모듈문제로 임시 비활성화 + // Temporarily disable no-console because of the widdershins module. "no-console": "off", }, - // JSDoc 설정 + // Configure JSDoc settings. settings: { jsdoc: { mode: "typescript", - // 태그 이름 설정 + // Configure tag name preferences. tagNamePreference: { returns: "returns", example: "example", @@ -138,6 +138,6 @@ export default tseslint.config( "max-nested-callbacks": "off", }, }, - // Prettier와의 충돌 방지 + // Prevent conflicts with Prettier. eslintConfigPrettier, ) diff --git a/examples/express/__tests__/expressApp.test.js b/examples/express/__tests__/expressApp.test.js index 028179cb..302a9f53 100644 --- a/examples/express/__tests__/expressApp.test.js +++ b/examples/express/__tests__/expressApp.test.js @@ -1,36 +1,89 @@ const app = require("../expressApp.js") const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") -const targetApp = app +const targetApp = app +describeAPI( + HttpMethod.POST, + "signup", + { + summary: "User Signup API", + tag: "Auth", + description: "Registers a user by receiving a username and password.", + }, + targetApp, + (apiDoc) => { + itDoc("Sign up successfully", async () => { + await apiDoc + .test() + .prettyPrint() + .req() + .body({ + username: field("Username", "username"), + password: field("Password", "P@ssw0rd123!@#"), + }) + .res() + .status(HttpStatus.CREATED) + }) + + itDoc("Fail to sign up without a username.", async () => { + await apiDoc + .test() + .req() + .body({ + password: field("Password", "P@ssw0rd123!@#"), + }) + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("Error message", "username is required"), + }) + }) + + itDoc("Fail to sign up if the password is shorter than eight characters.", async () => { + await apiDoc + .test() + .req() + .body({ + username: field("Username", "penekhun"), + password: field("Password", "1234567"), + }) + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("Error message", "password must be at least 8 characters"), + }) + }) + }, +) describeAPI( HttpMethod.GET, "/users/:userId", { - summary: "사용자 조회 API", + summary: "User lookup API", tag: "User", - description: "특정 사용자의 상세 정보를 조회하는 API입니다.", + description: "Retrieves detailed information about a specific user.", }, targetApp, (apiDoc) => { - itDoc("유효한 사용자 ID가 주어지면 200 응답을 반환한다.", async () => { + itDoc("Return 200 when a valid user ID is provided.", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("유효한 사용자 ID", "penek"), + userId: field("Valid user ID", "penek"), }) .res() .status(HttpStatus.OK) .body({ - userId: field("유저 ID", "penek"), - username: field("유저 이름", "hun"), - email: field("유저 이메일", "penekhun@gmail.com"), - friends: field("유저의 친구", ["zagabi", "json"]), + userId: field("User ID", "penek"), + username: field("User name", "hun"), + email: field("User email", "penekhun@gmail.com"), + friends: field("User friends", ["zagabi", "json"]), }) }) - itDoc("존재하지 않는 사용자 ID가 주어지면 404 응답을 반환한다.", async () => { + itDoc("Return 404 when the user ID does not exist.", async () => { await apiDoc .test() .req() @@ -47,42 +100,42 @@ describeAPI( HttpMethod.DELETE, "/users/:userId/friends/:friendName", { - summary: "특정 사용자의 친구를 삭제합니다.", + summary: "Deletes a specific user's friend.", tag: "User", - description: "특정 사용자의 특정 친구 삭제 API", + description: "Delete a specific friend for a user.", }, targetApp, (apiDoc) => { - itDoc("존재 하지 않는 사용자 ID가 주어지면 400 응답을 반환한다.", async () => { + itDoc("Return 400 when the user ID does not exist.", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("존재하지 않는 사용자 ID", "invalid-user-id"), + userId: field("Nonexistent user ID", "invalid-user-id"), }) .res() .status(HttpStatus.BAD_REQUEST) }) - itDoc("존재하지 않는 친구 ID가 주어지면 404 응답을 반환한다.", async () => { + itDoc("Return 404 when the friend ID does not exist.", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("유효한 사용자 ID", "penek"), - friendName: field("존재하지 않는 친구 이름", "invalid-friend-name"), + userId: field("Valid user ID", "penek"), + friendName: field("Nonexistent friend name", "invalid-friend-name"), }) .res() .status(HttpStatus.NOT_FOUND) }) - itDoc("유효한 사용자 ID와 친구 ID가 주어지면 정상 삭제된다.", async () => { + itDoc("Delete successfully when both the user ID and friend ID are valid.", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("유효한 사용자 ID", "penek"), - friendName: field("유효한 친구 이름", "zagabi"), + userId: field("Valid user ID", "penek"), + friendName: field("Valid friend name", "zagabi"), }) .res() .status(HttpStatus.NO_CONTENT) @@ -94,30 +147,30 @@ describeAPI( HttpMethod.GET, "/users", { - summary: "회원 목록 조회 API", + summary: "User list API", tag: "User", - description: "회원 목록을 조회합니다.", + description: "Retrieve the user list.", }, targetApp, (apiDoc) => { - itDoc("회원 목록을 조회한다.", async () => { + itDoc("Retrieve the user list.", async () => { await apiDoc .test() .req() .queryParam({ - page: field("페이지", 1), - size: field("페이지 사이즈", 3), + page: field("Page", 1), + size: field("Page size", 3), }) .res() .status(HttpStatus.OK) .body({ page: 1, - size: field("페이지 사이즈", 3), - total: field("전체 회원 수", 6), - members: field("회원 목록", [ + size: field("Page size", 3), + total: field("Total number of users", 6), + members: field("User list", [ { - username: field("사용자 아이디", "penekhun"), - name: field("사용자 이름(본명)", "seonghun"), + username: field("User ID", "penekhun"), + name: field("User real name", "seonghun"), }, { username: "zagabi", name: "hongchul" }, { username: "json", name: "jaesong" }, @@ -125,7 +178,7 @@ describeAPI( }) }) - itDoc("페이지 번호가 누락 되면 400 응답을 반환한다.", async () => { + itDoc("Return 400 when the page number is missing.", async () => { await apiDoc .test() .req() @@ -135,11 +188,11 @@ describeAPI( .res() .status(HttpStatus.BAD_REQUEST) .body({ - error: field("에러 메시지", "page are required"), + error: field("Error message", "page are required"), }) }) - itDoc("페이지 사이즈가 누락 되면 400 응답을 반환한다.", async () => { + itDoc("Return 400 when the page size is missing.", async () => { await apiDoc .test() .req() @@ -159,24 +212,24 @@ describeAPI( HttpMethod.GET, "/secret", { - summary: "비밀 API", + summary: "Secret API", tag: "Secret", - description: "비밀 API 입니다. 인증이 필요합니다.", + description: "Secret API that requires authentication.", }, targetApp, (apiDoc) => { - itDoc("인증 토큰이 없으면 접근할 수 없다.", async () => { + itDoc("Deny access when the auth token is missing.", async () => { await apiDoc.test().req().res().status(HttpStatus.UNAUTHORIZED) }) - itDoc("인증 토큰이 있으면 접근할 수 있다.", async () => { + itDoc("Allow access when the auth token is present.", async () => { const token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyMDI1MDQwNiIsIm5hbWUiOiJpdGRvYyIsImFkbWluIjp0cnVlLCJpYXQiOjE3NDM5MjQzNDEsImV4cCI6MTc0MzkyNzk0MX0.LXswgSAv_hjAH3KntMqnr-aLxO4ZytGeXk5q8lzzUM8" await apiDoc .test() .req() .header({ - Authorization: field("인증 토큰", "Bearer 123456"), + Authorization: field("Auth token", "Bearer 123456"), }) .res() .status(HttpStatus.OK) @@ -186,7 +239,7 @@ describeAPI( Authorization: `Bearer ${token}`, }) .body({ - message: field("비밀 메시지", "This is a secret message"), + message: field("Secret message", "This is a secret message"), }) }) }, @@ -196,27 +249,27 @@ describeAPI( HttpMethod.PUT, "/users/:userId", { - summary: "사용자 정보 수정 API", + summary: "User update API", tag: "User", - description: "사용자 ID를 받아 해당 사용자의 정보를 전체 수정합니다.", + description: "Updates a user's information entirely by user ID.", }, targetApp, (apiDoc) => { - itDoc("유효한 사용자 정보로 수정 성공", async () => { + itDoc("Update successfully with valid user information", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("유효한 사용자 ID", "user123"), + userId: field("Valid user ID", "user123"), }) .body({ - name: field("이름", "홍길동"), - email: field("이메일", "hong@example.com"), - age: field("나이", 30), + name: field("Name", "Hong Gil-dong"), + email: field("Email", "hong@example.com"), + age: field("Age", 30), address: { - city: field("도시", "서울"), - street: field("거리", "강남대로 123"), - zipcode: field("우편번호", "06000"), + city: field("City", "Seoul"), + street: field("Street", "123 Gangnam-daero"), + zipcode: field("Postal code", "06000"), }, }) .res() @@ -227,22 +280,22 @@ describeAPI( }) }) - itDoc("존재하지 않는 사용자 수정 시도", async () => { + itDoc("Attempt to update a nonexistent user", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("존재하지 않는 ID", "nonexistent"), + userId: field("Nonexistent ID", "nonexistent"), }) .body({ - name: "홍길동", + name: "Hong Gil-dong", email: "hong@example.com", }) .res() .status(HttpStatus.NOT_FOUND) .body({ success: false, - message: field("에러 메시지", "User not found"), + message: field("Error message", "User not found"), }) }) }, @@ -252,21 +305,21 @@ describeAPI( HttpMethod.PATCH, "/users/:userId", { - summary: "사용자 부분 정보 수정 API", + summary: "User partial update API", tag: "User", - description: "사용자 ID를 받아 해당 사용자의 정보를 부분적으로 수정합니다.", + description: "Updates a user's information partially by user ID.", }, targetApp, (apiDoc) => { - itDoc("이메일만 수정 성공", async () => { + itDoc("Update only the email successfully", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("유효한 사용자 ID", "user123"), + userId: field("Valid user ID", "user123"), }) .body({ - email: field("새 이메일", "newemail@example.com"), + email: field("New email", "newemail@example.com"), }) .res() .status(HttpStatus.OK) @@ -283,40 +336,40 @@ describeAPI( HttpMethod.POST, "/orders", { - summary: "주문 생성 API", + summary: "Order creation API", tag: "Order", - description: "새로운 주문을 생성합니다.", + description: "Creates a new order.", }, targetApp, (apiDoc) => { - itDoc("복잡한 주문 생성 성공", async () => { + itDoc("Create a complex order successfully", async () => { await apiDoc .test() .req() .header({ - Authorization: field("인증 토큰", "Bearer token123"), - "X-Request-ID": field("요청 ID", "req-12345"), + Authorization: field("Auth token", "Bearer token123"), + "X-Request-ID": field("Request ID", "req-12345"), }) .body({ customer: { - id: field("고객 ID", "cust123"), - name: field("고객명", "홍길동"), + id: field("Customer ID", "cust123"), + name: field("Customer name", "Hong Gil-dong"), contact: { - email: field("이메일", "hong@example.com"), - phone: field("전화번호", "010-1234-5678"), + email: field("Email", "hong@example.com"), + phone: field("Phone number", "010-1234-5678"), }, }, - items: field("주문 상품 목록", [ + items: field("Order item list", [ { productId: "prod1", - name: "노트북", + name: "Laptop", price: 1500000, quantity: 1, options: ["8GB RAM", "512GB SSD"], }, { productId: "prod2", - name: "마우스", + name: "Mouse", price: 30000, quantity: 2, options: [], @@ -324,29 +377,29 @@ describeAPI( ]), shipping: { address: { - zipcode: field("우편번호", "06000"), - city: field("도시", "서울"), - street: field("상세주소", "강남대로 123"), + zipcode: field("Postal code", "06000"), + city: field("City", "Seoul"), + street: field("Detailed address", "123 Gangnam-daero"), }, - method: field("배송 방법", "express"), - instructions: field("배송 지침", "부재시 경비실에 맡겨주세요"), + method: field("Delivery method", "express"), + instructions: field("Delivery instructions", "Leave it with security when absent."), }, payment: { - method: field("결제 방법", "credit_card"), + method: field("Payment method", "credit_card"), details: { - cardType: field("카드 종류", "visa"), - lastFourDigits: field("마지막 4자리", "1234"), + cardType: field("Card type", "visa"), + lastFourDigits: field("Last four digits", "1234"), }, }, - couponCodes: field("쿠폰 코드", ["SUMMER10", "WELCOME"]), + couponCodes: field("Coupon codes", ["SUMMER10", "WELCOME"]), }) .res() .status(HttpStatus.CREATED) .body({ - orderId: field("주문 ID", "order123"), - totalAmount: field("총 금액", 1560000), - estimatedDelivery: field("예상 배송일", "2023-09-15"), - status: field("주문 상태", "PAYMENT_PENDING"), + orderId: field("Order ID", "order123"), + totalAmount: field("Total amount", 1560000), + estimatedDelivery: field("Estimated delivery date", "2023-09-15"), + status: field("Order status", "PAYMENT_PENDING"), }) }) }, @@ -356,42 +409,42 @@ describeAPI( HttpMethod.GET, "/products", { - summary: "상품 검색 API", + summary: "Product search API", tag: "Product", - description: "다양한 조건으로 상품을 검색합니다.", + description: "Search products with a variety of conditions.", }, targetApp, (apiDoc) => { - itDoc("다양한 검색 조건으로 상품 검색", async () => { + itDoc("Search products with diverse filters", async () => { await apiDoc .test() .req() .queryParam({ - category: field("카테고리", "electronics"), - minPrice: field("최소 가격", 50000), - maxPrice: field("최대 가격", 2000000), - brands: field("브랜드 목록", ["samsung", "lg", "apple"]), - sort: field("정렬 기준", "price_asc"), - inStock: field("재고 있음 여부", true), - page: field("페이지 번호", 1), - pageSize: field("페이지 크기", 20), - features: field("특징", "wireless,bluetooth"), + category: field("Category", "electronics"), + minPrice: field("Minimum price", 50000), + maxPrice: field("Maximum price", 2000000), + brands: field("Brand list", ["samsung", "lg", "apple"]), + sort: field("Sort order", "price_asc"), + inStock: field("In-stock status", true), + page: field("Page number", 1), + pageSize: field("Page size", 20), + features: field("Features", "wireless,bluetooth"), }) .res() .status(HttpStatus.OK) .body({ - products: field("상품 목록", [ - { id: "prod1", name: "무선 마우스", price: 50000, brand: "samsung" }, - { id: "prod2", name: "블루투스 키보드", price: 120000, brand: "lg" }, + products: field("Product list", [ + { id: "prod1", name: "Wireless Mouse", price: 50000, brand: "samsung" }, + { id: "prod2", name: "Bluetooth Keyboard", price: 120000, brand: "lg" }, ]), pagination: { currentPage: 1, pageSize: 20, - totalItems: field("전체 상품 수", 42), - totalPages: field("전체 페이지 수", 3), + totalItems: field("Total number of products", 42), + totalPages: field("Total number of pages", 3), }, filters: { - appliedFilters: field("적용된 필터", [ + appliedFilters: field("Applied filters", [ "category", "minPrice", "maxPrice", @@ -408,18 +461,18 @@ describeAPI( HttpMethod.GET, "/cached-data", { - summary: "캐시된 데이터 조회 API", + summary: "Cached data API", tag: "System", - description: "HTTP 캐싱 메커니즘을 활용하여 데이터를 조회합니다.", + description: "Retrieves data using HTTP caching mechanisms.", }, targetApp, (apiDoc) => { - itDoc("If-None-Match 헤더로 캐시 활용", async () => { + itDoc("Leverage the If-None-Match header for caching", async () => { await apiDoc .test() .req() .header({ - "If-None-Match": field("ETag 값", '"abc123"'), + "if-none-match": field("ETag value", '"abc123"'), Accept: "application/json", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", }) @@ -427,66 +480,15 @@ describeAPI( .status(HttpStatus.NOT_MODIFIED) }) - itDoc("신선한 데이터 조회", async () => { + itDoc("Retrieve fresh data", async () => { await apiDoc .test() .req() .res() .status(HttpStatus.OK) .body({ - data: field("데이터", { version: "1.0", content: "캐시 가능한 데이터" }), - timestamp: field("타임스탬프", 1697873280000), - }) - }) - }, -) - -describeAPI( - HttpMethod.POST, - "/validate", - { - summary: "데이터 유효성 검증 API", - tag: "Validation", - description: "다양한 형태의 데이터 유효성을 검증하고 상세한 오류 정보를 제공합니다.", - }, - targetApp, - (apiDoc) => { - itDoc("다양한 필드 유효성 오류", async () => { - await apiDoc - .test() - .req() - .body({ - username: field("잘못된 사용자명", "a"), - email: field("잘못된 이메일", "not-an-email"), - age: field("잘못된 나이", -5), - registrationDate: field("잘못된 날짜", "2023-13-45"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - success: false, - errors: field("오류 목록", [ - { - field: "username", - message: "Username must be at least 3 characters", - code: "MIN_LENGTH", - }, - { - field: "email", - message: "Invalid email format", - code: "INVALID_FORMAT", - }, - { - field: "age", - message: "Age must be a positive number", - code: "POSITIVE_NUMBER", - }, - { - field: "registrationDate", - message: "Invalid date format", - code: "INVALID_DATE", - }, - ]), + data: field("Data", { version: "1.0", content: "Cacheable data" }), + timestamp: field("Timestamp", 1697873280000), }) }) }, @@ -495,20 +497,20 @@ describeAPI( HttpMethod.GET, "/failed-test", { - summary: "테스트 실패 유도 API", + summary: "Failure-inducing API", tag: "Test", - description: "일부러 실패하는 응답을 주는 API입니다.", + description: "Deliberately returns a failing response.", }, targetApp, (apiDoc) => { - itDoc("404 응답을 의도적으로 반환", async () => { + itDoc("Intentionally return a 404 response", async () => { await apiDoc .test() .req() .res() .status(HttpStatus.NOT_FOUND) .body({ - message: field("실패 메시지", "This endpoint is designed to make tests fail"), + message: field("Failure message", "This endpoint is designed to make tests fail"), }) }) }, diff --git a/examples/express/__tests__/expressApp2.test.js b/examples/express/__tests__/expressApp2.test.js deleted file mode 100644 index a0067ef6..00000000 --- a/examples/express/__tests__/expressApp2.test.js +++ /dev/null @@ -1,58 +0,0 @@ -const app = require("../expressApp.js") -const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") - -const targetApp = app - -describeAPI( - HttpMethod.POST, - "signup", - { - summary: "회원 가입 API", - tag: "Auth", - description: "사용자로 부터 아이디와 패스워드를 받아 회원가입을 수행합니다.", - }, - targetApp, - (apiDoc) => { - itDoc("회원가입 성공", async () => { - await apiDoc - .test() - .prettyPrint() - .req() - .body({ - username: field("사용자 이름", "username"), - password: field("패스워드", "P@ssw0rd123!@#"), - }) - .res() - .status(HttpStatus.CREATED) - }) - - itDoc("아이디를 입력하지 않으면 회원가입 실패한다.", async () => { - await apiDoc - .test() - .req() - .body({ - password: field("패스워드", "P@ssw0rd123!@#"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - error: field("에러 메시지", "username is required"), - }) - }) - - itDoc("패스워드가 8자 미만이면 회원가입 실패한다.", async () => { - await apiDoc - .test() - .req() - .body({ - username: field("아이디", "penekhun"), - password: field("패스워드", "1234567"), - }) - .res() - .status(HttpStatus.BAD_REQUEST) - .body({ - error: field("에러 메시지", "password must be at least 8 characters"), - }) - }) - }, -) \ No newline at end of file diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index 9a51277f..f69402cf 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -14,9 +14,9 @@ "paths": { "/signup": { "post": { - "summary": "회원 가입 API", + "summary": "User Signup API", "tags": ["Auth"], - "description": "사용자로 부터 아이디와 패스워드를 받아 회원가입을 수행합니다.", + "description": "Registers a user by receiving a username and password.", "operationId": "postSignup", "requestBody": { "content": { @@ -27,12 +27,12 @@ "username": { "type": "string", "example": "username", - "description": "사용자 이름" + "description": "Username" }, "password": { "type": "string", "example": "P@ssw0rd123!@#", - "description": "패스워드" + "description": "Password" } }, "required": ["username", "password"] @@ -48,10 +48,10 @@ "security": [{}], "responses": { "201": { - "description": "회원가입 성공" + "description": "Sign up successfully" }, "400": { - "description": "아이디를 입력하지 않으면 회원가입 실패한다.", + "description": "Fail to sign up without a username.", "content": { "application/json; charset=utf-8": { "schema": { @@ -60,24 +60,24 @@ "error": { "type": "string", "example": "username is required", - "description": "에러 메시지" + "description": "Error message" } }, "required": ["error"] }, "examples": { - "아이디를 입력하지 않으면 회원가입 실패한다.": { + "Fail to sign up without a username.": { "value": { "error": { - "message": "아이디를 입력하지 않으면 회원가입 실패한다.", + "message": "Fail to sign up without a username.", "code": "ERROR_400" } } }, - "패스워드가 8자 미만이면 회원가입 실패한다.": { + "Fail to sign up if the password is shorter than eight characters.": { "value": { "error": { - "message": "패스워드가 8자 미만이면 회원가입 실패한다.", + "message": "Fail to sign up if the password is shorter than eight characters.", "code": "ERROR_400" } } @@ -91,9 +91,9 @@ }, "/users/:userId": { "get": { - "summary": "사용자 조회 API", + "summary": "User lookup API", "tags": ["User"], - "description": "특정 사용자의 상세 정보를 조회하는 API입니다.", + "description": "Retrieves detailed information about a specific user.", "operationId": "getUsersByuserid", "parameters": [ { @@ -103,14 +103,14 @@ "schema": { "type": "string" }, - "description": "유효한 사용자 ID", + "description": "Valid user ID", "example": "penek" } ], "security": [{}], "responses": { "200": { - "description": "유효한 사용자 ID가 주어지면 200 응답을 반환한다.", + "description": "Return 200 when a valid user ID is provided.", "content": { "application/json; charset=utf-8": { "schema": { @@ -119,18 +119,18 @@ "userId": { "type": "string", "example": "penek", - "description": "유저 ID" + "description": "User ID" }, "username": { "type": "string", "example": "hun", - "description": "유저 이름" + "description": "User name" }, "email": { "type": "string", "format": "email", "example": "penekhun@gmail.com", - "description": "유저 이메일" + "description": "User email" }, "friends": { "type": "array", @@ -138,14 +138,14 @@ "type": "string", "example": "zagabi" }, - "description": "유저의 친구", + "description": "User friends", "example": ["zagabi", "json"] } }, "required": ["userId", "username", "email", "friends"] }, "examples": { - "유효한 사용자 ID가 주어지면 200 응답을 반환한다.": { + "Return 200 when a valid user ID is provided.": { "value": { "userId": "penek", "username": "hun", @@ -158,14 +158,14 @@ } }, "404": { - "description": "존재하지 않는 사용자 ID가 주어지면 404 응답을 반환한다." + "description": "Return 404 when the user ID does not exist." } } }, "put": { - "summary": "사용자 정보 수정 API", + "summary": "User update API", "tags": ["User"], - "description": "사용자 ID를 받아 해당 사용자의 정보를 전체 수정합니다.", + "description": "Updates a user's information entirely by user ID.", "operationId": "putUsersByuserid", "parameters": [ { @@ -175,7 +175,7 @@ "schema": { "type": "string" }, - "description": "유효한 사용자 ID", + "description": "Valid user ID", "example": "user123" } ], @@ -187,37 +187,37 @@ "properties": { "name": { "type": "string", - "example": "홍길동", - "description": "이름" + "example": "Hong Gil-dong", + "description": "Name" }, "email": { "type": "string", "format": "email", "example": "hong@example.com", - "description": "이메일" + "description": "Email" }, "age": { "type": "integer", "example": 30, - "description": "나이" + "description": "Age" }, "address": { "type": "object", "properties": { "city": { "type": "string", - "example": "서울", - "description": "도시" + "example": "Seoul", + "description": "City" }, "street": { "type": "string", - "example": "강남대로 123", - "description": "거리" + "example": "123 Gangnam-daero", + "description": "Street" }, "zipcode": { "type": "string", "example": "06000", - "description": "우편번호" + "description": "Postal code" } }, "required": ["city", "street", "zipcode"] @@ -226,12 +226,12 @@ "required": ["name", "email", "age"] }, "example": { - "name": "홍길동", + "name": "Hong Gil-dong", "email": "hong@example.com", "age": 30, "address": { - "city": "서울", - "street": "강남대로 123", + "city": "Seoul", + "street": "123 Gangnam-daero", "zipcode": "06000" } } @@ -242,7 +242,7 @@ "security": [{}], "responses": { "200": { - "description": "유효한 사용자 정보로 수정 성공", + "description": "Update successfully with valid user information", "content": { "application/json; charset=utf-8": { "schema": { @@ -259,7 +259,7 @@ } }, "examples": { - "유효한 사용자 정보로 수정 성공": { + "Update successfully with valid user information": { "value": { "success": true, "message": "User updated successfully" @@ -270,7 +270,7 @@ } }, "404": { - "description": "존재하지 않는 사용자 수정 시도", + "description": "Attempt to update a nonexistent user", "content": { "application/json; charset=utf-8": { "schema": { @@ -283,16 +283,16 @@ "message": { "type": "string", "example": "User not found", - "description": "에러 메시지" + "description": "Error message" } }, "required": ["message"] }, "examples": { - "존재하지 않는 사용자 수정 시도": { + "Attempt to update a nonexistent user": { "value": { "error": { - "message": "존재하지 않는 사용자 수정 시도", + "message": "Attempt to update a nonexistent user", "code": "ERROR_404" } } @@ -304,9 +304,9 @@ } }, "patch": { - "summary": "사용자 부분 정보 수정 API", + "summary": "User partial update API", "tags": ["User"], - "description": "사용자 ID를 받아 해당 사용자의 정보를 부분적으로 수정합니다.", + "description": "Updates a user's information partially by user ID.", "operationId": "patchUsersByuserid", "parameters": [ { @@ -316,7 +316,7 @@ "schema": { "type": "string" }, - "description": "유효한 사용자 ID", + "description": "Valid user ID", "example": "user123" } ], @@ -330,7 +330,7 @@ "type": "string", "format": "email", "example": "newemail@example.com", - "description": "새 이메일" + "description": "New email" } }, "required": ["email"] @@ -345,7 +345,7 @@ "security": [{}], "responses": { "200": { - "description": "이메일만 수정 성공", + "description": "Update only the email successfully", "content": { "application/json; charset=utf-8": { "schema": { @@ -369,7 +369,7 @@ } }, "examples": { - "이메일만 수정 성공": { + "Update only the email successfully": { "value": { "success": true, "message": "User partially updated", @@ -385,9 +385,9 @@ }, "/users/:userId/friends/:friendName": { "delete": { - "summary": "특정 사용자의 친구를 삭제합니다.", + "summary": "Deletes a specific user's friend.", "tags": ["User"], - "description": "특정 사용자의 특정 친구 삭제 API", + "description": "Delete a specific friend for a user.", "operationId": "deleteUsersByuseridFriendsByfriendname", "parameters": [ { @@ -397,7 +397,7 @@ "schema": { "type": "string" }, - "description": "존재하지 않는 사용자 ID", + "description": "Nonexistent user ID", "example": "invalid-user-id" }, { @@ -413,22 +413,22 @@ "security": [{}], "responses": { "204": { - "description": "유효한 사용자 ID와 친구 ID가 주어지면 정상 삭제된다." + "description": "Delete successfully when both the user ID and friend ID are valid." }, "400": { - "description": "존재 하지 않는 사용자 ID가 주어지면 400 응답을 반환한다." + "description": "Return 400 when the user ID does not exist." }, "404": { - "description": "존재하지 않는 친구 ID가 주어지면 404 응답을 반환한다." + "description": "Return 404 when the friend ID does not exist." } } } }, "/users": { "get": { - "summary": "회원 목록 조회 API", + "summary": "User list API", "tags": ["User"], - "description": "회원 목록을 조회합니다.", + "description": "Retrieve the user list.", "operationId": "getUsers", "parameters": [ { @@ -437,10 +437,10 @@ "schema": { "type": "integer", "example": 1, - "description": "페이지" + "description": "Page" }, "required": true, - "description": "페이지" + "description": "Page" }, { "name": "size", @@ -448,16 +448,16 @@ "schema": { "type": "integer", "example": 3, - "description": "페이지 사이즈" + "description": "Page size" }, "required": true, - "description": "페이지 사이즈" + "description": "Page size" } ], "security": [{}], "responses": { "200": { - "description": "회원 목록을 조회한다.", + "description": "Retrieve the user list.", "content": { "application/json; charset=utf-8": { "schema": { @@ -470,12 +470,12 @@ "size": { "type": "integer", "example": 3, - "description": "페이지 사이즈" + "description": "Page size" }, "total": { "type": "integer", "example": 6, - "description": "전체 회원 수" + "description": "Total number of users" }, "members": { "type": "array", @@ -485,26 +485,26 @@ "username": { "type": "string", "example": "penekhun", - "description": "사용자 아이디" + "description": "User ID" }, "name": { "type": "string", "example": "seonghun", - "description": "사용자 이름(본명)" + "description": "User real name" } }, "required": ["username", "name"] }, - "description": "회원 목록", + "description": "User list", "example": [ { "username": { - "description": "사용자 아이디", + "description": "User ID", "example": "penekhun", "required": true }, "name": { - "description": "사용자 이름(본명)", + "description": "User real name", "example": "seonghun", "required": true } @@ -523,7 +523,7 @@ "required": ["size", "total", "members"] }, "examples": { - "회원 목록을 조회한다.": { + "Retrieve the user list.": { "value": { "page": 1, "size": 3, @@ -531,12 +531,12 @@ "members": [ { "username": { - "description": "사용자 아이디", + "description": "User ID", "example": "penekhun", "required": true }, "name": { - "description": "사용자 이름(본명)", + "description": "User real name", "example": "seonghun", "required": true } @@ -557,7 +557,7 @@ } }, "400": { - "description": "페이지 번호가 누락 되면 400 응답을 반환한다.", + "description": "Return 400 when the page number is missing.", "content": { "application/json; charset=utf-8": { "schema": { @@ -566,24 +566,24 @@ "error": { "type": "string", "example": "page are required", - "description": "에러 메시지" + "description": "Error message" } }, "required": ["error"] }, "examples": { - "페이지 번호가 누락 되면 400 응답을 반환한다.": { + "Return 400 when the page number is missing.": { "value": { "error": { - "message": "페이지 번호가 누락 되면 400 응답을 반환한다.", + "message": "Return 400 when the page number is missing.", "code": "ERROR_400" } } }, - "페이지 사이즈가 누락 되면 400 응답을 반환한다.": { + "Return 400 when the page size is missing.": { "value": { "error": { - "message": "페이지 사이즈가 누락 되면 400 응답을 반환한다.", + "message": "Return 400 when the page size is missing.", "code": "ERROR_400" } } @@ -597,9 +597,9 @@ }, "/secret": { "get": { - "summary": "비밀 API", + "summary": "Secret API", "tags": ["Secret"], - "description": "비밀 API 입니다. 인증이 필요합니다.", + "description": "Secret API that requires authentication.", "operationId": "getSecret", "security": [ { @@ -608,7 +608,7 @@ ], "responses": { "200": { - "description": "인증 토큰이 있으면 접근할 수 있다.", + "description": "Allow access when the auth token is present.", "content": { "application/json; charset=utf-8": { "schema": { @@ -617,13 +617,13 @@ "message": { "type": "string", "example": "This is a secret message", - "description": "비밀 메시지" + "description": "Secret message" } }, "required": ["message"] }, "examples": { - "인증 토큰이 있으면 접근할 수 있다.": { + "Allow access when the auth token is present.": { "value": { "message": "This is a secret message" } @@ -633,20 +633,20 @@ } }, "401": { - "description": "인증 토큰이 없으면 접근할 수 없다." + "description": "Deny access when the auth token is missing." } } } }, "/orders": { "post": { - "summary": "주문 생성 API", + "summary": "Order creation API", "tags": ["Order"], - "description": "새로운 주문을 생성합니다.", + "description": "Creates a new order.", "operationId": "postOrders", "parameters": [ { - "name": "X-Request-ID", + "name": "x-request-id", "in": "header", "schema": { "type": "string", @@ -667,12 +667,12 @@ "id": { "type": "string", "example": "cust123", - "description": "고객 ID" + "description": "Customer ID" }, "name": { "type": "string", - "example": "홍길동", - "description": "고객명" + "example": "Hong Gil-dong", + "description": "Customer name" }, "contact": { "type": "object", @@ -681,12 +681,12 @@ "type": "string", "format": "email", "example": "hong@example.com", - "description": "이메일" + "description": "Email" }, "phone": { "type": "string", "example": "010-1234-5678", - "description": "전화번호" + "description": "Phone number" } }, "required": ["email", "phone"] @@ -705,7 +705,7 @@ }, "name": { "type": "string", - "example": "노트북" + "example": "Laptop" }, "price": { "type": "integer", @@ -724,18 +724,18 @@ } } }, - "description": "주문 상품 목록", + "description": "Order item list", "example": [ { "productId": "prod1", - "name": "노트북", + "name": "Laptop", "price": 1500000, "quantity": 1, "options": ["8GB RAM", "512GB SSD"] }, { "productId": "prod2", - "name": "마우스", + "name": "Mouse", "price": 30000, "quantity": 2, "options": [] @@ -751,17 +751,17 @@ "zipcode": { "type": "string", "example": "06000", - "description": "우편번호" + "description": "Postal code" }, "city": { "type": "string", - "example": "서울", - "description": "도시" + "example": "Seoul", + "description": "City" }, "street": { "type": "string", - "example": "강남대로 123", - "description": "상세주소" + "example": "123 Gangnam-daero", + "description": "Detailed address" } }, "required": ["zipcode", "city", "street"] @@ -769,12 +769,12 @@ "method": { "type": "string", "example": "express", - "description": "배송 방법" + "description": "Delivery method" }, "instructions": { "type": "string", - "example": "부재시 경비실에 맡겨주세요", - "description": "배송 지침" + "example": "Leave it with security when absent.", + "description": "Delivery instructions" } }, "required": ["method", "instructions"] @@ -785,7 +785,7 @@ "method": { "type": "string", "example": "credit_card", - "description": "결제 방법" + "description": "Payment method" }, "details": { "type": "object", @@ -793,12 +793,12 @@ "cardType": { "type": "string", "example": "visa", - "description": "카드 종류" + "description": "Card type" }, "lastFourDigits": { "type": "string", "example": "1234", - "description": "마지막 4자리" + "description": "Last four digits" } }, "required": ["cardType", "lastFourDigits"] @@ -812,7 +812,7 @@ "type": "string", "example": "SUMMER10" }, - "description": "쿠폰 코드", + "description": "Coupon codes", "example": ["SUMMER10", "WELCOME"] } }, @@ -821,7 +821,7 @@ "example": { "customer": { "id": "cust123", - "name": "홍길동", + "name": "Hong Gil-dong", "contact": { "email": "hong@example.com", "phone": "010-1234-5678" @@ -830,14 +830,14 @@ "items": [ { "productId": "prod1", - "name": "노트북", + "name": "Laptop", "price": 1500000, "quantity": 1, "options": ["8GB RAM", "512GB SSD"] }, { "productId": "prod2", - "name": "마우스", + "name": "Mouse", "price": 30000, "quantity": 2, "options": [] @@ -846,11 +846,11 @@ "shipping": { "address": { "zipcode": "06000", - "city": "서울", - "street": "강남대로 123" + "city": "Seoul", + "street": "123 Gangnam-daero" }, "method": "express", - "instructions": "부재시 경비실에 맡겨주세요" + "instructions": "Leave it with security when absent." }, "payment": { "method": "credit_card", @@ -872,7 +872,7 @@ ], "responses": { "201": { - "description": "복잡한 주문 생성 성공", + "description": "Create a complex order successfully", "content": { "application/json; charset=utf-8": { "schema": { @@ -881,23 +881,23 @@ "orderId": { "type": "string", "example": "order123", - "description": "주문 ID" + "description": "Order ID" }, "totalAmount": { "type": "integer", "example": 1560000, - "description": "총 금액" + "description": "Total amount" }, "estimatedDelivery": { "type": "string", "format": "date", "example": "2023-09-15", - "description": "예상 배송일" + "description": "Estimated delivery date" }, "status": { "type": "string", "example": "PAYMENT_PENDING", - "description": "주문 상태" + "description": "Order status" } }, "required": [ @@ -908,7 +908,7 @@ ] }, "examples": { - "복잡한 주문 생성 성공": { + "Create a complex order successfully": { "value": { "orderId": "order123", "totalAmount": 1560000, @@ -925,9 +925,9 @@ }, "/products": { "get": { - "summary": "상품 검색 API", + "summary": "Product search API", "tags": ["Product"], - "description": "다양한 조건으로 상품을 검색합니다.", + "description": "Search products with a variety of conditions.", "operationId": "getProducts", "parameters": [ { @@ -936,10 +936,10 @@ "schema": { "type": "string", "example": "electronics", - "description": "카테고리" + "description": "Category" }, "required": true, - "description": "카테고리" + "description": "Category" }, { "name": "minPrice", @@ -947,10 +947,10 @@ "schema": { "type": "integer", "example": 50000, - "description": "최소 가격" + "description": "Minimum price" }, "required": true, - "description": "최소 가격" + "description": "Minimum price" }, { "name": "maxPrice", @@ -958,10 +958,10 @@ "schema": { "type": "integer", "example": 2000000, - "description": "최대 가격" + "description": "Maximum price" }, "required": true, - "description": "최대 가격" + "description": "Maximum price" }, { "name": "brands", @@ -972,11 +972,11 @@ "type": "string", "example": "samsung" }, - "description": "브랜드 목록", + "description": "Brand list", "example": ["samsung", "lg", "apple"] }, "required": true, - "description": "브랜드 목록" + "description": "Brand list" }, { "name": "sort", @@ -984,10 +984,10 @@ "schema": { "type": "string", "example": "price_asc", - "description": "정렬 기준" + "description": "Sort order" }, "required": true, - "description": "정렬 기준" + "description": "Sort order" }, { "name": "inStock", @@ -995,10 +995,10 @@ "schema": { "type": "boolean", "example": true, - "description": "재고 있음 여부" + "description": "In-stock status" }, "required": true, - "description": "재고 있음 여부" + "description": "In-stock status" }, { "name": "page", @@ -1006,10 +1006,10 @@ "schema": { "type": "integer", "example": 1, - "description": "페이지 번호" + "description": "Page number" }, "required": true, - "description": "페이지 번호" + "description": "Page number" }, { "name": "pageSize", @@ -1017,10 +1017,10 @@ "schema": { "type": "integer", "example": 20, - "description": "페이지 크기" + "description": "Page size" }, "required": true, - "description": "페이지 크기" + "description": "Page size" }, { "name": "features", @@ -1028,16 +1028,16 @@ "schema": { "type": "string", "example": "wireless,bluetooth", - "description": "특징" + "description": "Features" }, "required": true, - "description": "특징" + "description": "Features" } ], "security": [{}], "responses": { "200": { - "description": "다양한 검색 조건으로 상품 검색", + "description": "Search products with diverse filters", "content": { "application/json; charset=utf-8": { "schema": { @@ -1054,7 +1054,7 @@ }, "name": { "type": "string", - "example": "무선 마우스" + "example": "Wireless Mouse" }, "price": { "type": "integer", @@ -1066,17 +1066,17 @@ } } }, - "description": "상품 목록", + "description": "Product list", "example": [ { "id": "prod1", - "name": "무선 마우스", + "name": "Wireless Mouse", "price": 50000, "brand": "samsung" }, { "id": "prod2", - "name": "블루투스 키보드", + "name": "Bluetooth Keyboard", "price": 120000, "brand": "lg" } @@ -1096,12 +1096,12 @@ "totalItems": { "type": "integer", "example": 42, - "description": "전체 상품 수" + "description": "Total number of products" }, "totalPages": { "type": "integer", "example": 3, - "description": "전체 페이지 수" + "description": "Total number of pages" } }, "required": ["totalItems", "totalPages"] @@ -1115,7 +1115,7 @@ "type": "string", "example": "category" }, - "description": "적용된 필터", + "description": "Applied filters", "example": [ "category", "minPrice", @@ -1131,18 +1131,18 @@ "required": ["products"] }, "examples": { - "다양한 검색 조건으로 상품 검색": { + "Search products with diverse filters": { "value": { "products": [ { "id": "prod1", - "name": "무선 마우스", + "name": "Wireless Mouse", "price": 50000, "brand": "samsung" }, { "id": "prod2", - "name": "블루투스 키보드", + "name": "Bluetooth Keyboard", "price": 120000, "brand": "lg" } @@ -1173,13 +1173,13 @@ }, "/cached-data": { "get": { - "summary": "캐시된 데이터 조회 API", + "summary": "Cached data API", "tags": ["System"], - "description": "HTTP 캐싱 메커니즘을 활용하여 데이터를 조회합니다.", + "description": "Retrieves data using HTTP caching mechanisms.", "operationId": "getCached-data", "parameters": [ { - "name": "If-None-Match", + "name": "if-none-match", "in": "header", "schema": { "type": "string", @@ -1188,7 +1188,7 @@ "required": false }, { - "name": "Accept", + "name": "accept", "in": "header", "schema": { "type": "string", @@ -1197,7 +1197,7 @@ "required": false }, { - "name": "Accept-Language", + "name": "accept-language", "in": "header", "schema": { "type": "string", @@ -1209,7 +1209,7 @@ "security": [{}], "responses": { "200": { - "description": "신선한 데이터 조회", + "description": "Retrieve fresh data", "content": { "application/json; charset=utf-8": { "schema": { @@ -1224,29 +1224,29 @@ }, "content": { "type": "string", - "example": "캐시 가능한 데이터" + "example": "Cacheable data" } }, - "description": "데이터", + "description": "Data", "example": { "version": "1.0", - "content": "캐시 가능한 데이터" + "content": "Cacheable data" } }, "timestamp": { "type": "integer", "example": 1697873280000, - "description": "타임스탬프" + "description": "Timestamp" } }, "required": ["data", "timestamp"] }, "examples": { - "신선한 데이터 조회": { + "Retrieve fresh data": { "value": { "data": { "version": "1.0", - "content": "캐시 가능한 데이터" + "content": "Cacheable data" }, "timestamp": 1697873280000 } @@ -1256,142 +1256,21 @@ } }, "304": { - "description": "If-None-Match 헤더로 캐시 활용" - } - } - } - }, - "/validate": { - "post": { - "summary": "데이터 유효성 검증 API", - "tags": ["Validation"], - "description": "다양한 형태의 데이터 유효성을 검증하고 상세한 오류 정보를 제공합니다.", - "operationId": "postValidate", - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "username": { - "type": "string", - "example": "a", - "description": "잘못된 사용자명" - }, - "email": { - "type": "string", - "example": "not-an-email", - "description": "잘못된 이메일" - }, - "age": { - "type": "integer", - "example": -5, - "description": "잘못된 나이" - }, - "registrationDate": { - "type": "string", - "format": "date", - "example": "2023-13-45", - "description": "잘못된 날짜" - } - }, - "required": ["username", "email", "age", "registrationDate"] - }, - "example": { - "username": "a", - "email": "not-an-email", - "age": -5, - "registrationDate": "2023-13-45" - } - } - }, - "required": true - }, - "security": [{}], - "responses": { - "400": { - "description": "다양한 필드 유효성 오류", - "content": { - "application/json; charset=utf-8": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "example": false - }, - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "field": { - "type": "string", - "example": "username" - }, - "message": { - "type": "string", - "example": "Username must be at least 3 characters" - }, - "code": { - "type": "string", - "example": "MIN_LENGTH" - } - } - }, - "description": "오류 목록", - "example": [ - { - "field": "username", - "message": "Username must be at least 3 characters", - "code": "MIN_LENGTH" - }, - { - "field": "email", - "message": "Invalid email format", - "code": "INVALID_FORMAT" - }, - { - "field": "age", - "message": "Age must be a positive number", - "code": "POSITIVE_NUMBER" - }, - { - "field": "registrationDate", - "message": "Invalid date format", - "code": "INVALID_DATE" - } - ] - } - }, - "required": ["errors"] - }, - "examples": { - "다양한 필드 유효성 오류": { - "value": { - "error": { - "message": "다양한 필드 유효성 오류", - "code": "ERROR_400" - } - } - } - } - } - } + "description": "Leverage the If-None-Match header for caching" } } } }, "/failed-test": { "get": { - "summary": "테스트 실패 유도 API", + "summary": "Failure-inducing API", "tags": ["Test"], - "description": "일부러 실패하는 응답을 주는 API입니다.", + "description": "Deliberately returns a failing response.", "operationId": "getFailed-test", "security": [{}], "responses": { "404": { - "description": "404 응답을 의도적으로 반환", + "description": "Intentionally return a 404 response", "content": { "application/json; charset=utf-8": { "schema": { @@ -1400,16 +1279,16 @@ "message": { "type": "string", "example": "This endpoint is designed to make tests fail", - "description": "실패 메시지" + "description": "Failure message" } }, "required": ["message"] }, "examples": { - "404 응답을 의도적으로 반환": { + "Intentionally return a 404 response": { "value": { "error": { - "message": "404 응답을 의도적으로 반환", + "message": "Intentionally return a 404 response", "code": "ERROR_404" } } diff --git a/examples/express/expressApp.js b/examples/express/expressApp.js index be30aa5c..8f80a411 100644 --- a/examples/express/expressApp.js +++ b/examples/express/expressApp.js @@ -5,26 +5,32 @@ const app = express() app.use(express.json()) app.post("/signup", function (req, res) { - const { username, password } = req.body - - if (!username) { - return res.status(400).json({ - error: "username is required", + try { + const { username, password } = req.body + + if (!username) { + return res.status(400).json({ + error: "username is required", + }) + } + + if (!password) { + return res.status(400).json({ + error: "password is required", + }) + } + + if (password.length < 8) { + return res.status(400).json({ + error: "password must be at least 8 characters", + }) + } + return res.status(201).json() + } catch (err) { + return res.status(500).json({ + error: "Internal Server Error", }) } - - if (!password) { - return res.status(400).json({ - error: "password is required", - }) - } - if (password.length < 8) { - return res.status(400).json({ - error: "password must be at least 8 characters", - }) - } - - return res.status(201).json() }) app.get("/users/:userId", (req, res) => { @@ -39,6 +45,7 @@ app.get("/users/:userId", (req, res) => { username: "hun", email: "penekhun@gmail.com", friends: ["zagabi", "json"], + // fetchedAt: new Date().toISOString(), }) }) @@ -97,8 +104,6 @@ app.get("/users", (req, res) => { error: "size are required", }) } - - // sample pagination const pageNumber = parseInt(page) const sizeNumber = parseInt(size) const startIndex = (pageNumber - 1) * sizeNumber @@ -120,7 +125,7 @@ app.get("/secret", (req, res) => { } res.set({ "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json", + "Content-Type": "application/json; charset=utf-8", "itdoc-custom-Header": "secret-header-value", Authorization: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIyMDI1MDQwNiIsIm5hbWUiOiJpdGRvYyIsImFkbWluIjp0cnVlLCJpYXQiOjE3NDM5MjQzNDEsImV4cCI6MTc0MzkyNzk0MX0.LXswgSAv_hjAH3KntMqnr-aLxO4ZytGeXk5q8lzzUM8", @@ -130,7 +135,6 @@ app.get("/secret", (req, res) => { }) }) -// PUT 요청으로 사용자 정보 수정 API app.put("/users/:userId", (req, res) => { const { userId } = req.params @@ -147,7 +151,6 @@ app.put("/users/:userId", (req, res) => { } }) -// PATCH 요청으로 사용자 부분 정보 수정 API app.patch("/users/:userId", (req, res) => { const { userId } = req.params const { email } = req.body @@ -166,36 +169,6 @@ app.patch("/users/:userId", (req, res) => { } }) -// 프로필 이미지 업로드 API -app.post("/users/:userId/profile-image", (req, res) => { - const { userId } = req.params - const contentType = req.headers["content-type"] - - if (!contentType || !contentType.includes("multipart/form-data")) { - return res.status(400).json({ - success: false, - message: "Content-Type must be multipart/form-data", - }) - } - - // 파일 확장자 확인 (테스트 목적) - const fileExtension = - req.body && req.body.image ? req.body.image.split(".").pop().toLowerCase() : "" - - if (["jpg", "jpeg", "png", "gif"].includes(fileExtension)) { - return res.status(200).json({ - success: true, - imageUrl: `https://example.com/images/${userId}.jpg`, - }) - } else { - return res.status(400).json({ - success: false, - message: "Unsupported file type. Only jpg, png, gif are allowed.", - }) - } -}) - -// 주문 생성 API app.post("/orders", (req, res) => { const { authorization } = req.headers @@ -206,7 +179,6 @@ app.post("/orders", (req, res) => { }) } - // 간단한 검증만 수행 const { customer, items } = req.body if (!customer || !items) { @@ -224,20 +196,18 @@ app.post("/orders", (req, res) => { }) }) -// 상품 검색 API app.get("/products", (req, res) => { - // 모든 쿼리 파라미터 제공되었다고 가정 return res.status(200).json({ products: [ { id: "prod1", - name: "무선 마우스", + name: "Wireless Mouse", price: 50000, brand: "samsung", }, { id: "prod2", - name: "블루투스 키보드", + name: "Bluetooth Keyboard", price: 120000, brand: "lg", }, @@ -254,10 +224,9 @@ app.get("/products", (req, res) => { }) }) -// 캐시된 데이터 조회 API app.get("/cached-data", (req, res) => { const ifNoneMatch = req.headers["if-none-match"] - + if (ifNoneMatch === '"abc123"') { res.setHeader("ETag", '"abc123"') res.setHeader("Cache-Control", "max-age=3600") @@ -269,66 +238,14 @@ app.get("/cached-data", (req, res) => { return res.status(200).json({ data: { version: "1.0", - content: "캐시 가능한 데이터", + content: "Cacheable data", }, timestamp: 1697873280000, }) } }) -// 데이터 유효성 검증 API -app.post("/validate", (req, res) => { - const { username, email, age, registrationDate } = req.body - const errors = [] - - if (!username || username.length < 3) { - errors.push({ - field: "username", - message: "Username must be at least 3 characters", - code: "MIN_LENGTH", - }) - } - - if (!email || !email.includes("@")) { - errors.push({ - field: "email", - message: "Invalid email format", - code: "INVALID_FORMAT", - }) - } - - if (typeof age !== "number" || age <= 0) { - errors.push({ - field: "age", - message: "Age must be a positive number", - code: "POSITIVE_NUMBER", - }) - } - - if (registrationDate && !Date.parse(registrationDate)) { - errors.push({ - field: "registrationDate", - message: "Invalid date format", - code: "INVALID_DATE", - }) - } - - if (errors.length > 0) { - return res.status(400).json({ - success: false, - errors, - }) - } - - return res.status(200).json({ - success: true, - message: "All fields are valid", - }) -}) - -// 의도적으로 실패하는 테스트를 위한 API 엔드포인트 app.get("/failed-test", (req, res) => { - // 테스트에서는 200(OK)을 기대하지만 404를 반환하여 의도적으로 실패 return res.status(404).json({ message: "This endpoint is designed to make tests fail", }) diff --git a/examples/express/scripts/run-tests-and-validate.js b/examples/express/scripts/run-tests-and-validate.js index 87f21888..f12a505f 100644 --- a/examples/express/scripts/run-tests-and-validate.js +++ b/examples/express/scripts/run-tests-and-validate.js @@ -28,8 +28,8 @@ const OUTPUT_FILENAME = "oas.json" * NOTE * * This script is used to run the tests and validate the OpenAPI Specification (OAS) output. - * 생성되는 OpenAPI.JSON과 예상되는 OpenAPI.JSON을 비교합니다. - * 만약 두 파일이 다르면 에러가 발생하니, OpenAPI 생성 로직이 변경되면 expected 파일도 변경해야 합니다. + * Compare the generated OpenAPI JSON with the expected OpenAPI JSON. + * If the files differ, throw an error and update the expected file whenever the OpenAPI generation logic changes. */ const cleanOutputDir = () => { diff --git a/examples/testframework-compatibility-test/__tests__/compatibility.test.ts b/examples/testframework-compatibility-test/__tests__/compatibility.test.ts index e528a0f8..36d39326 100644 --- a/examples/testframework-compatibility-test/__tests__/compatibility.test.ts +++ b/examples/testframework-compatibility-test/__tests__/compatibility.test.ts @@ -15,7 +15,7 @@ */ /** - * 어댑터를 통해 제공받은 테스트 프레임워크의 DSL 기능을 테스트하는 코드 + * Validate the DSL features exposed by the adapter-provided test framework. * @see {@link https://github.com/do-pa/itdoc/issues/35} */ @@ -75,7 +75,7 @@ describeCommon("TestFramework DSL Functionality", () => { "afterCommon", ] - // 실행 순서 검증 + // Verify the execution order. if (hookOrder.join(",") !== expectedHookOrder.join(",")) { throw new Error( `Hook order mismatch.\nExpected: ${expectedHookOrder.join( diff --git a/itdoc-doc/docs/experiments/LLM.mdx b/itdoc-doc/docs/experiments/LLM.mdx index 619e7f8a..6101ea10 100644 --- a/itdoc-doc/docs/experiments/LLM.mdx +++ b/itdoc-doc/docs/experiments/LLM.mdx @@ -194,62 +194,4 @@ itdoc generate --app {YOUR_APP_FILE_PATH} |------------------------------------------------------------------------|--------------------------------| | --app (-a) | Root app source code file path | -When you run this command, it analyzes the Express application defined in `{YOUR_APP_FILE_PATH}` and automatically generates tests for the API endpoints in that application. - -:::info[itdoc does not send source code to external servers] -It creates an `API Spec Markdown` through its own AST analysis and sends this to ChatGPT to generate test cases. -Therefore, you can use it with confidence as your source code is not sent to external servers. -::: - -### Generating Tests from API Spec Markdown - -You can automatically generate tests based on an `API Spec Markdown` file with the following command: - -```bash -itdoc generate --p {API_Spec_Markdown_FILE_PATH} -itdoc generate --path {API_Spec_Markdown_FILE_PATH} -``` - -| Option | Description | -|-------------------------------------------------------------------------|-------------------------------| -| --path (-p) | `API Spec Markdown` file path | - -The document format should follow this structure: - -```markdown title="api-specs.md" {1-4} -`HTTP_METHOD` `ENDPOINT` -- Test Case: Test case title - - Request: Description of the request - - Response: Description of the response -``` - -Here are examples for various API cases: - - - - - ```markdown title="Authentication API Example" - GET /secret - - Test Case: Access secret message with proper authentication - - Request: Send GET request with valid authentication header - - Response: Status code 200, JSON response with secret message and specific headers - - Test Case: Unauthorized access - - Request: Send GET request with invalid authentication header - - Response: Returns status code 401 - ``` - - - - - ```markdown title="File Upload API Example" - POST /users/:userId/profile-image - - Test Case: Successfully upload profile image - - Request: Send POST request with valid image file and "multipart/form-data" content type - - Response: Status code 200, JSON response with image URL - - Test Case: Wrong content type - - Request: Send POST request without "multipart/form-data" content type - - Response: Status code 400, JSON response with error message - ``` - - - +When you run this command, it analyzes the Express application defined in `{YOUR_APP_FILE_PATH}` and automatically generates tests for the API endpoints in that application. \ No newline at end of file diff --git a/itdoc-doc/docusaurus.config.ts b/itdoc-doc/docusaurus.config.ts index 83dbcb00..3602783f 100644 --- a/itdoc-doc/docusaurus.config.ts +++ b/itdoc-doc/docusaurus.config.ts @@ -59,7 +59,7 @@ const config: Config = { ], ], themeConfig: { - image: "img/logo.jpg", + image: "img/logo.png", navbar: { title: "itdoc", logo: { diff --git a/lib/__tests__/unit/config/logger.test.ts b/lib/__tests__/unit/config/logger.test.ts index dad9f2e2..4b075e48 100644 --- a/lib/__tests__/unit/config/logger.test.ts +++ b/lib/__tests__/unit/config/logger.test.ts @@ -30,21 +30,23 @@ describe("logger", () => { consoleStub.restore() }) - context("info 호출시", () => { - it("로그가 잘 출력된다.", () => { + context("when info is called", () => { + it("prints formatted logs", () => { logger.info( - "회원 가입 성공", + "User sign-up succeeded", { username: "penekhun", name: "MoonSeonghun", }, - "가입 시기 : 2025-01-01", + "Joined at: 2025-01-01", ) const [labelLine, ...extraLines] = consoleStub.getCalls().map((c) => c.args[0]) expect(labelLine).to.equal( - chalk.bgBlue(chalk.white.bold("[INFO]")) + " " + chalk.blue("회원 가입 성공"), + chalk.bgBlue(chalk.white.bold("[INFO]")) + + " " + + chalk.blue("User sign-up succeeded"), ) expect(extraLines).to.deep.equal([ @@ -52,19 +54,19 @@ describe("logger", () => { ' "username": "penekhun",\n' + ' "name": "MoonSeonghun"\n' + " }", - " ↳ 가입 시기 : 2025-01-01", + " ↳ Joined at: 2025-01-01", ]) }) }) - context("ITDOC_DEBUG 환경변수가 ", () => { - it("설정되지 않으면 로그레벨이 4가 된다.", async () => { + context("when ITDOC_DEBUG is toggled", () => { + it("sets the log level to 4 when the variable is unset", async () => { delete process.env.ITDOC_DEBUG const { default: logger } = await import("../../../config/logger?" + Date.now()) expect(logger.level).to.equal(4) }) - it("설정되면 로그레벨이 0이 된다.", async () => { + it("sets the log level to 0 when the variable is enabled", async () => { process.env.ITDOC_DEBUG = "true" const { default: logger } = await import("../../../config/logger?" + Date.now()) expect(logger.level).to.equal(0) diff --git a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts index 26860f41..fbd25299 100644 --- a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts +++ b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts @@ -40,8 +40,8 @@ describe("OpenAPIGenerator", () => { Object.defineProperty(OpenAPIGenerator, "getInstance", { value: originalGetInstance }) }) - describe("응답 본문 처리", () => { - it("응답 본문이 비어있으면 content를 포함하지 않아야 한다", () => { + describe("response body handling", () => { + it("does not include content when the response body is empty", () => { const testResult: TestResult = { method: HttpMethod.GET, url: "/test/empty", @@ -51,7 +51,7 @@ describe("OpenAPIGenerator", () => { status: 200, body: {}, }, - testSuiteDescription: "빈 응답 본문", + testSuiteDescription: "Empty response body", } generator.collectTestResult(testResult) @@ -59,10 +59,13 @@ describe("OpenAPIGenerator", () => { assert.isDefined(spec.paths["/test/empty"].get.responses["200"]) assert.isUndefined(spec.paths["/test/empty"].get.responses["200"].content) - assert.equal(spec.paths["/test/empty"].get.responses["200"].description, "빈 응답 본문") + assert.equal( + spec.paths["/test/empty"].get.responses["200"].description, + "Empty response body", + ) }) - it("응답 본문이 null이면 content를 포함하지 않아야 한다", () => { + it("does not include content when the response body is null", () => { const testResult: TestResult = { method: HttpMethod.GET, url: "/test/null", @@ -72,7 +75,7 @@ describe("OpenAPIGenerator", () => { status: 200, body: null, }, - testSuiteDescription: "null 응답 본문", + testSuiteDescription: "Null response body", } generator.collectTestResult(testResult) @@ -82,11 +85,11 @@ describe("OpenAPIGenerator", () => { assert.isUndefined(spec.paths["/test/null"].get.responses["200"].content) assert.equal( spec.paths["/test/null"].get.responses["200"].description, - "null 응답 본문", + "Null response body", ) }) - it("응답 본문이 명시적으로 정의되지 않으면 content가 생성되지 않아야 한다", () => { + it("does not create content when the response body is undefined", () => { const testResult: TestResult = { method: HttpMethod.GET, url: "/test/undefined-body", @@ -95,7 +98,7 @@ describe("OpenAPIGenerator", () => { response: { status: 400, }, - testSuiteDescription: "응답 본문 미정의 에러", + testSuiteDescription: "Undefined response body error", } generator.collectTestResult(testResult) @@ -105,11 +108,11 @@ describe("OpenAPIGenerator", () => { assert.isUndefined(spec.paths["/test/undefined-body"].get.responses["400"].content) assert.equal( spec.paths["/test/undefined-body"].get.responses["400"].description, - "응답 본문 미정의 에러", + "Undefined response body error", ) }) - it("명시적으로 응답 본문이 정의된 에러 응답은 error 객체 구조로 생성되어야 한다", () => { + it("creates an error object when the error response body is defined", () => { const testResult: TestResult = { method: HttpMethod.GET, url: "/test/error", @@ -117,9 +120,9 @@ describe("OpenAPIGenerator", () => { request: {}, response: { status: 404, - body: { message: "리소스를 찾을 수 없습니다" }, + body: { message: "Resource not found" }, }, - testSuiteDescription: "존재하지 않는 리소스 요청", + testSuiteDescription: "Missing resource request", } generator.collectTestResult(testResult) @@ -131,32 +134,32 @@ describe("OpenAPIGenerator", () => { const contentTypeKey = Object.keys( spec.paths["/test/error"].get.responses["404"].content, )[0] - assert.isDefined(contentTypeKey, "content-type 키가 존재해야 합니다") + assert.isDefined(contentTypeKey, "content-type key should exist") const content = spec.paths["/test/error"].get.responses["404"].content[contentTypeKey] - assert.isDefined(content, "content 객체가 존재해야 합니다") - assert.isDefined(content.schema, "schema가 존재해야 합니다") + assert.isDefined(content, "content object should exist") + assert.isDefined(content.schema, "schema should exist") if (content.schema.properties && content.schema.properties.error) { - assert.isDefined(content.examples, "examples가 존재해야 합니다") + assert.isDefined(content.examples, "examples should exist") const exampleKey = Object.keys(content.examples)[0] - assert.isDefined(exampleKey, "example 키가 존재해야 합니다") + assert.isDefined(exampleKey, "example key should exist") const exampleValue = content.examples[exampleKey].value - assert.isDefined(exampleValue, "example 값이 존재해야 합니다") + assert.isDefined(exampleValue, "example value should exist") - assert.isDefined(exampleValue.error, "error 객체가 존재해야 합니다") - assert.isDefined(exampleValue.error.message, "error.message가 존재해야 합니다") - assert.equal(exampleValue.error.message, "존재하지 않는 리소스 요청") + assert.isDefined(exampleValue.error, "error object should exist") + assert.isDefined(exampleValue.error.message, "error.message should exist") + assert.equal(exampleValue.error.message, "Missing resource request") } else { - assert.isDefined(content.examples, "examples가 존재해야 합니다") + assert.isDefined(content.examples, "examples should exist") const exampleKey = Object.keys(content.examples)[0] - assert.isDefined(content.examples[exampleKey], "example이 존재해야 합니다") + assert.isDefined(content.examples[exampleKey], "example should exist") } }) - it("명시적으로 응답 본문이 정의된 성공 응답은 원본 응답 구조를 유지해야 한다", () => { - const responseBody = { id: 1, name: "테스트 데이터" } + it("retains the original shape when a success response body is defined", () => { + const responseBody = { id: 1, name: "test data" } const testResult: TestResult = { method: HttpMethod.GET, url: "/test/success", @@ -166,7 +169,7 @@ describe("OpenAPIGenerator", () => { status: 200, body: responseBody, }, - testSuiteDescription: "성공적인 응답", + testSuiteDescription: "Successful response", } generator.collectTestResult(testResult) @@ -178,17 +181,17 @@ describe("OpenAPIGenerator", () => { const contentTypeKey = Object.keys( spec.paths["/test/success"].get.responses["200"].content, )[0] - assert.isDefined(contentTypeKey, "content-type 키가 존재해야 합니다") + assert.isDefined(contentTypeKey, "content-type key should exist") const content = spec.paths["/test/success"].get.responses["200"].content[contentTypeKey] - assert.isDefined(content, "content 객체가 존재해야 합니다") - assert.isDefined(content.schema, "schema가 존재해야 합니다") + assert.isDefined(content, "content object should exist") + assert.isDefined(content.schema, "schema should exist") - assert.isDefined(content.examples, "examples가 존재해야 합니다") + assert.isDefined(content.examples, "examples should exist") const exampleKey = Object.keys(content.examples)[0] - assert.isDefined(exampleKey, "example 키가 존재해야 합니다") - assert.isDefined(content.examples[exampleKey], "example이 존재해야 합니다") - assert.isDefined(content.examples[exampleKey].value, "example 값이 존재해야 합니다") + assert.isDefined(exampleKey, "example key should exist") + assert.isDefined(content.examples[exampleKey], "example should exist") + assert.isDefined(content.examples[exampleKey].value, "example value should exist") if ( typeof content.examples[exampleKey].value === "object" && @@ -200,7 +203,7 @@ describe("OpenAPIGenerator", () => { } }) - it("동일한 상태 코드에 대해 모든 테스트 케이스가 본문을 정의하지 않으면 content가 생성되지 않아야 한다", () => { + it("does not create content when no test case defines a body for the same status", () => { const testResult1: TestResult = { method: HttpMethod.GET, url: "/test/no-body-responses", @@ -209,7 +212,7 @@ describe("OpenAPIGenerator", () => { response: { status: 400, }, - testSuiteDescription: "본문 없는 테스트 1", + testSuiteDescription: "Bodyless test #1", } const testResult2: TestResult = { @@ -220,7 +223,7 @@ describe("OpenAPIGenerator", () => { response: { status: 400, }, - testSuiteDescription: "본문 없는 테스트 2", + testSuiteDescription: "Bodyless test #2", } generator.collectTestResult(testResult1) @@ -231,4 +234,42 @@ describe("OpenAPIGenerator", () => { assert.isUndefined(spec.paths["/test/no-body-responses"].get.responses["400"].content) }) }) + + describe("normalizePathTemplate", () => { + it("should handle paths without parameters", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/users") + assert.strictEqual(normalized, "/users") + }) + + it("should handle mixed format paths", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/users/{userId}/posts/:postId") + assert.strictEqual(normalized, "/users/{userId}/posts/{postId}") + }) + + it("should handle parameters with underscores", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/items/:item_id") + assert.strictEqual(normalized, "/items/{item_id}") + }) + + it("should handle empty path", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("") + assert.strictEqual(normalized, "") + }) + + it("should handle root path", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/") + assert.strictEqual(normalized, "/") + }) + + it("should handle hyphenated parameter names", () => { + const generator = OpenAPIGenerator.getInstance() + const normalized = generator["normalizePathTemplate"]("/files/:file-name") + assert.strictEqual(normalized, "/files/{file-name}") + }) + }) }) diff --git a/lib/__tests__/unit/dsl/interface/field.test.ts b/lib/__tests__/unit/dsl/interface/field.test.ts index 10d72897..6fad40da 100644 --- a/lib/__tests__/unit/dsl/interface/field.test.ts +++ b/lib/__tests__/unit/dsl/interface/field.test.ts @@ -17,8 +17,8 @@ import { expect } from "chai" import { field } from "../../../../dsl" -describe("field() 는", () => { - it("3번째 인자를 생략하면 required가 true로 설정된다.", () => { +describe("field()", () => { + it("sets required to true when the third argument is omitted.", () => { expect(field("description", "example")).deep.equal({ description: "description", example: "example", @@ -26,7 +26,7 @@ describe("field() 는", () => { }) }) - it("3번째 인자를 false로 설정하면 required가 false로 설정된다.", () => { + it("sets required to false when the third argument is false.", () => { expect(field("description", "example", false)).deep.equal({ description: "description", example: "example", @@ -34,7 +34,7 @@ describe("field() 는", () => { }) }) - it("null 값을 example로 사용할 수 있다.", () => { + it("allows using null as the example value.", () => { expect(field("nullable field", null)).deep.equal({ description: "nullable field", example: null, @@ -42,7 +42,7 @@ describe("field() 는", () => { }) }) - it("null 값을 example로 사용하고 required를 false로 설정할 수 있다.", () => { + it("allows using null as the example value while setting required to false.", () => { expect(field("optional nullable field", null, false)).deep.equal({ description: "optional nullable field", example: null, diff --git a/lib/__tests__/unit/dsl/interface/header.test.ts b/lib/__tests__/unit/dsl/interface/header.test.ts index 52dd9038..595a3b79 100644 --- a/lib/__tests__/unit/dsl/interface/header.test.ts +++ b/lib/__tests__/unit/dsl/interface/header.test.ts @@ -17,8 +17,8 @@ import { expect } from "chai" import { header } from "../../../../dsl/interface/header" -describe("header() 는", () => { - it("3번째 인자를 생략하면 required가 true로 설정된다.", () => { +describe("header()", () => { + it("sets required to true when the third argument is omitted.", () => { expect(header("description", "example")).deep.equal({ description: "description", example: "example", @@ -26,7 +26,7 @@ describe("header() 는", () => { }) }) - it("3번째 인자를 false로 설정하면 required가 false로 설정된다.", () => { + it("sets required to false when the third argument is false.", () => { expect(header("description", "example", false)).deep.equal({ description: "description", example: "example", diff --git a/lib/__tests__/unit/dsl/test-builders/vaildateResponse.test.ts b/lib/__tests__/unit/dsl/test-builders/vaildateResponse.test.ts index 8189f4b5..9e78b4cc 100644 --- a/lib/__tests__/unit/dsl/test-builders/vaildateResponse.test.ts +++ b/lib/__tests__/unit/dsl/test-builders/vaildateResponse.test.ts @@ -18,189 +18,177 @@ import { expect } from "chai" import { validateResponse } from "../../../../dsl/test-builders/validateResponse" import { field } from "../../../../dsl" -describe("validateResponse 함수 검증", () => { - describe("field를 사용하지 않는 경우", () => { - it("단순 원시 타입이 올바른 경우 에러가 발생하지 않아야 한다", () => { - const expected = { a: 1, b: "테스트", c: true } - const actual = { a: 1, b: "테스트", c: true } +describe("validateResponse function", () => { + describe("when field is not used", () => { + it("does not throw when primitive values match", () => { + const expected = { a: 1, b: "test", c: true } + const actual = { a: 1, b: "test", c: true } expect(() => validateResponse(expected, actual)).to.not.throw() }) ;[ { - expected: { - a: 1, - }, - actual: { - a: 2, - }, + expected: { a: 1 }, + actual: { a: 2 }, throwMessage: "Expected response body[a] to be 1 but got 2", }, { - expected: { - a: true, - }, - actual: { - a: false, - }, + expected: { a: true }, + actual: { a: false }, throwMessage: "Expected response body[a] to be true but got false", }, { - expected: { - a: "a", - }, - actual: { - a: "b", - }, + expected: { a: "a" }, + actual: { a: "b" }, throwMessage: "Expected response body[a] to be a but got b", }, - ].forEach((obj) => { - it("원시타입에 대해서 값이 불일치하면 에러가 발생해야 한다.", () => { - const { expected, actual, throwMessage } = obj + ].forEach((scenario) => { + it("throws when primitive values differ", () => { + const { expected, actual, throwMessage } = scenario expect(() => validateResponse(expected, actual)).to.throw(throwMessage) }) }) - it("중첩 객체가 올바른 경우 에러가 발생하지 않아야 한다", () => { + it("does not throw when nested objects match", () => { const expected = { - 사용자: { + user: { id: 1, - 이름: "철수", + name: "Chulsoo", }, } const actual = { - 사용자: { + user: { id: 1, - 이름: "철수", + name: "Chulsoo", }, } expect(() => validateResponse(expected, actual)).to.not.throw() }) - it("중첩 객체의 값이 불일치하면 에러가 발생해야 한다", () => { + it("throws when nested object values differ", () => { const expected = { - 사용자: { + user: { id: 1, - 이름: "철수", + name: "Chulsoo", }, } const actual = { - 사용자: { + user: { id: 1, - 이름: "영희", + name: "Younghee", }, } expect(() => validateResponse(expected, actual)).to.throw( - "Expected response body[사용자.이름] to be 철수 but got 영희", + "Expected response body[user.name] to be Chulsoo but got Younghee", ) }) - it("배열이 올바른 경우 에러가 발생하지 않아야 한다", () => { - const expected = { 목록: [1, 2, 3] } - const actual = { 목록: [1, 2, 3] } + it("does not throw when arrays match", () => { + const expected = { list: [1, 2, 3] } + const actual = { list: [1, 2, 3] } expect(() => validateResponse(expected, actual)).to.not.throw() }) - it("배열 길이가 다르면 에러가 발생해야 한다", () => { - const expected = { 목록: [1, 2, 3] } - const actual = { 목록: [1, 2] } + it("throws when array lengths differ", () => { + const expected = { list: [1, 2, 3] } + const actual = { list: [1, 2] } expect(() => validateResponse(expected, actual)).to.throw( - "Expected response body[목록] to have length 3 but got 2", + "Expected response body[list] to have length 3 but got 2", ) }) }) - describe("field 객체를 사용한 경우", () => { - it("DSL 필드의 example 함수 happy case", () => { + describe("when using field objects", () => { + it("handles a DSL field example function happy case", () => { const expected = { - 값: field("값 정보", (val) => { + value: field("Value info", (val) => { if (val !== 42) { - throw new Error("값이 42가 아닙니다") + throw new Error("Value is not 42") } }), } - const actual = { 값: 42 } + const actual = { value: 42 } expect(() => validateResponse(expected, actual)).to.not.throw() }) - it("DSL 필드의 example 함수 검증이 실패하면 에러가 발생해야 한다", () => { + it("throws when the DSL field example function fails", () => { const expected = { - 값: field("값 정보", (val) => { + value: field("Value info", (val) => { if (val !== 42) { - throw new Error("값이 42가 아닙니다") + throw new Error("Value is not 42") } }), } - const actual = { 값: -10 } - expect(() => validateResponse(expected, actual)).to.throw("값이 42가 아닙니다") + const actual = { value: -10 } + expect(() => validateResponse(expected, actual)).to.throw("Value is not 42") }) - it("DSL 필드에서 null 값이 일치하면 에러가 발생하지 않아야 한다", () => { + it("does not throw when a DSL field with null matches", () => { const expected = { - 값: field("null 값", null), + value: field("Null value", null), } - const actual = { 값: null } + const actual = { value: null } expect(() => validateResponse(expected, actual)).to.not.throw() }) - it("DSL 필드에서 null 값이 불일치하면 에러가 발생해야 한다", () => { + it("throws when a DSL field with null differs", () => { const expected = { - 값: field("null 값", null), + value: field("Null value", null), } - const actual = { 값: "not null" } + const actual = { value: "not null" } expect(() => validateResponse(expected, actual)).to.throw( - "Expected response body[값] to be null but got not null", + "Expected response body[value] to be null but got not null", ) }) - it("일반 객체에서 null 값이 일치하면 에러가 발생하지 않아야 한다", () => { - const expected = { 값: null } - const actual = { 값: null } + it("does not throw when null values match in plain objects", () => { + const expected = { value: null } + const actual = { value: null } expect(() => validateResponse(expected, actual)).to.not.throw() }) - it("일반 객체에서 null 값이 불일치하면 에러가 발생해야 한다", () => { - const expected = { 값: null } - const actual = { 값: "not null" } + it("throws when null values differ in plain objects", () => { + const expected = { value: null } + const actual = { value: "not null" } expect(() => validateResponse(expected, actual)).to.throw( - "Expected response body[값] to be null but got not null", + "Expected response body[value] to be null but got not null", ) }) }) - describe("중첩 데이터 구조의 경우", () => { - it("중첩 구조에서 데이터가 똑같은 경우 에러가 발생하지 않는다.", () => { + describe("for nested data structures", () => { + it("does not throw when nested structures match", () => { const expected = { - 데이터: field("검색 결과", { + data: field("Search result", { id: field("pk", { - 항목: field("세부 항목", ["다", "라"]), + items: field("Detailed items", ["C", "D"]), }), }), } const actual = { - 데이터: field("검색 결과", { + data: field("Search result", { id: field("pk", { - 항목: field("세부 항목", ["다", "라"]), + items: field("Detailed items", ["C", "D"]), }), }), } expect(() => validateResponse(expected, actual)).to.not.throw() }) - it("중첩 구조에서 값이 다르면 에러가 발생한다.", () => { + it("throws when nested structures differ", () => { const expected = { - 데이터: [ - { id: 1, 항목: ["가", "나"] }, - { id: 2, 항목: ["다", "라"] }, + data: [ + { id: 1, items: ["A", "B"] }, + { id: 2, items: ["C", "D"] }, ], } const actual = { - 데이터: [ - { id: 1, 항목: ["가", "나"] }, - { id: "잘못된 값", 항목: ["다", "라"] }, + data: [ + { id: 1, items: ["A", "B"] }, + { id: "invalid value", items: ["C", "D"] }, ], } expect(() => validateResponse(expected, actual)).to.throw( - "Expected response body[데이터[1].id] to be 2 but got 잘못된 값", + "Expected response body[data[1].id] to be 2 but got invalid value", ) }) }) diff --git a/lib/config/getOpenAPIConfig.ts b/lib/config/getOpenAPIConfig.ts index de028ca2..6780d3a6 100644 --- a/lib/config/getOpenAPIConfig.ts +++ b/lib/config/getOpenAPIConfig.ts @@ -17,21 +17,21 @@ import { readItdocConfig } from "./readPackageJson" /** - * 생성될 OAS에 설정된 서버 주소를 가져옴. + * Retrieve the configured server URL for the generated OAS. */ export function getOpenAPIBaseUrl(): string { return readItdocConfig("document.baseUrl", "http://localhost:8080") } /** - * 생성될 OAS에 설정될 TITLE을 가져옴., + * Retrieve the title that will be applied to the generated OAS. */ export function getOpenAPITitle(): string { return readItdocConfig("document.title", "API Document") } /** - * 생성될 OAS에 설정될 Top-Level 문서 설명을 가져옴. + * Retrieve the top-level description for the generated OAS document. */ export function getOpenAPIDocumentDescription(): string { return readItdocConfig( diff --git a/lib/config/logger.ts b/lib/config/logger.ts index ad2b36eb..eb74f7d5 100644 --- a/lib/config/logger.ts +++ b/lib/config/logger.ts @@ -14,13 +14,11 @@ * limitations under the License. */ -/* eslint-disable no-console */ - import { ConsolaReporter, createConsola, LogObject, consola as defaultConsola } from "consola" import chalk from "chalk" import { LoggerInterface } from "./LoggerInterface" -// 테스트 환경 감지 +// Detect whether the current execution is a test environment. const isTestEnv = (): boolean => { return ( process.env.NODE_ENV === "test" || @@ -31,10 +29,10 @@ const isTestEnv = (): boolean => { ) } -// 테스트 완료 여부 추적 (Jest 환경에서만 사용) +// Track whether tests have finished (used only in Jest environments). let testsCompleted = false -// Jest 환경에서 테스트 완료 후 로그 안전하게 처리 +// Safely handle logs after tests finish in Jest environments. try { if (isTestEnv() && typeof (global as any).afterAll === "function") { ;(global as any).afterAll(() => { @@ -42,7 +40,7 @@ try { }) } } catch { - // 무시 + // Ignore on purpose. } const DEFAULT_LOG_LEVEL = process.env.ITDOC_DEBUG ? 0 : 4 @@ -66,16 +64,16 @@ const levels = { } as const const MAX_LOG_LEVEL_LABEL_LENGTH = Math.max(...Object.keys(levels).map((key) => key.length)) -// 안전한 로그 출력 함수 +// Provide a safe logging helper. const safeConsoleLog = (message: string, ...args: any[]): void => { - // 테스트 모드이고 테스트가 완료된 상태라면 로그 출력 안함 + // Skip logging when running in test mode after tests complete. if (isTestEnv() && testsCompleted) { return } - // 테스트 모드이지만 테스트가 아직 완료되지 않았으면, 콘솔 사용 + // Use the console when tests are still running in test mode. if (isTestEnv()) { - // 테스트 중에는 stdout에 직접 쓰기 (Jest의 console.log 대체) + // Write directly to stdout during tests as a console.log replacement. process.stdout.write(message + "\n") if (args.length > 0) { args.forEach((arg) => { @@ -83,12 +81,12 @@ const safeConsoleLog = (message: string, ...args: any[]): void => { const str = typeof arg === "string" ? arg : JSON.stringify(arg, null, 2) process.stdout.write(str + "\n") } catch { - // 무시 + // Ignore on purpose. } }) } } else { - // 일반 환경에서는 console.log 사용 + // Use console.log in non-test environments. console.log(message, ...args) } } @@ -139,7 +137,7 @@ const formatExtra = (extra: unknown[]): string[] => { } const itdocLoggerInstance = createConsola({ - level: isTestEnv() ? 5 : DEFAULT_LOG_LEVEL, // 테스트 환경에서는 로깅 레벨을 더 높게 설정 (오류만 표시) + level: isTestEnv() ? 5 : DEFAULT_LOG_LEVEL, // Raise the log level in test environments to show only errors. reporters: [customReporter], }) diff --git a/lib/dsl/generator/OpenAPIGenerator.ts b/lib/dsl/generator/OpenAPIGenerator.ts index c2f55ecf..313c0e9a 100644 --- a/lib/dsl/generator/OpenAPIGenerator.ts +++ b/lib/dsl/generator/OpenAPIGenerator.ts @@ -34,6 +34,9 @@ interface OpenAPIInfo { /** * OpenAPI Specification generator + * + * It operates in a Singleton pattern and collects test results + * Create a Specification document in OpenAPI 3.0.0 format. */ export class OpenAPIGenerator implements IOpenAPIGenerator { private testResults: TestResult[] = [] @@ -41,7 +44,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { private version: string = "1.0.0" private description: string = getOpenAPIDocumentDescription() private servers: Array<{ url: string; description?: string }> = [] - private defaultSecurity: Record[] = [{}] // 기본값은 빈 보안 요구사항 (선택적 보안) + private defaultSecurity: Record[] = [{}] // Default to an empty optional security requirement. private operationBuilder = new OperationBuilder() private utilityBuilder = new UtilityBuilder() @@ -113,8 +116,6 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { groupedResults.get(path)!.get(method)!.get(statusCode)!.push(result) } - logger.info("Grouped test results:", groupedResults) - return groupedResults } @@ -129,10 +130,15 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { const paths: Record> = {} for (const [path, methods] of groupedResults) { - paths[path] = {} + const normalizedPath = this.normalizePathTemplate(path) + paths[normalizedPath] = {} for (const [method, statusCodes] of methods) { - paths[path][method] = this.generateOperationObject(path, method, statusCodes) + paths[normalizedPath][method] = this.generateOperationObject( + normalizedPath, + method, + statusCodes, + ) } } @@ -490,7 +496,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { */ private selectRepresentativeResult(results: TestResult[]): TestResult { const authTestCase = results.find( - (result) => result.request.headers && "Authorization" in result.request.headers, + (result) => result.request.headers && "authorization" in result.request.headers, ) if (authTestCase) { @@ -532,8 +538,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { */ private validatePathParameters(paths: Record>): void { for (const [path, pathItem] of Object.entries(paths)) { - const pathParamMatches = path.match(/:([^/]+)/g) || [] - const pathParams = pathParamMatches.map((param) => param.slice(1)) + const pathParams = this.extractPathParameterNames(path) if (pathParams.length === 0) continue @@ -570,6 +575,30 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { } } + /** + * Converts colon-prefixed Express parameters to OpenAPI-compatible templates. + * @param {string} path Raw route path + * @returns {string} Normalized OpenAPI path + */ + private normalizePathTemplate(path: string): string { + return path.replace(/:([A-Za-z0-9_-]+)/g, "{$1}") + } + + /** + * Extracts parameter names from a normalized or raw path template. + * @param {string} path Path string potentially containing parameters + * @returns {string[]} Parameter names + */ + private extractPathParameterNames(path: string): string[] { + const braceMatches = path.match(/\{([^}]+)\}/g) || [] + if (braceMatches.length > 0) { + return braceMatches.map((param) => param.slice(1, -1)) + } + + const colonMatches = path.match(/:([^/]+)/g) || [] + return colonMatches.map((param) => param.slice(1)) + } + /** * Normalizes examples according to the schema. * @param {any} example Example value diff --git a/lib/dsl/generator/builders/OperationBuilder.ts b/lib/dsl/generator/builders/OperationBuilder.ts index b160e302..06aed3b2 100644 --- a/lib/dsl/generator/builders/OperationBuilder.ts +++ b/lib/dsl/generator/builders/OperationBuilder.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -// 재구성된 모듈을 내보냅니다 export * from "./operation" diff --git a/lib/dsl/generator/builders/operation/SecurityBuilder.ts b/lib/dsl/generator/builders/operation/SecurityBuilder.ts index 5bad9e3b..f2d16d03 100644 --- a/lib/dsl/generator/builders/operation/SecurityBuilder.ts +++ b/lib/dsl/generator/builders/operation/SecurityBuilder.ts @@ -32,8 +32,8 @@ export class SecurityBuilder implements SecurityBuilderInterface { public extractSecurityRequirements(result: TestResult): Array> { const security: Array> = [] - if (result.request.headers && "Authorization" in result.request.headers) { - const authHeaderValue = result.request.headers["Authorization"] + if (result.request.headers && "authorization" in result.request.headers) { + const authHeaderValue = result.request.headers["authorization"] let authHeader = "" if (typeof authHeaderValue === "string") { diff --git a/lib/dsl/generator/builders/schema/SchemaFactory.ts b/lib/dsl/generator/builders/schema/SchemaFactory.ts index 83ef15c2..df9fd66d 100644 --- a/lib/dsl/generator/builders/schema/SchemaFactory.ts +++ b/lib/dsl/generator/builders/schema/SchemaFactory.ts @@ -74,28 +74,28 @@ export class SchemaFactory implements ISchemaFactory { return { type: "null" } } - // DSL 필드 처리 + // Handle DSL fields. if (isDSLField(value)) { return this.generators["dslfield"].generateSchema(value, includeExample) } - // 배열 처리 + // Handle arrays. if (Array.isArray(value)) { return this.generators["array"].generateSchema(value, includeExample) } - // 객체 처리 + // Handle objects. if (typeof value === "object") { return this.generators["object"].generateSchema(value, includeExample) } - // 기본 타입 처리 (문자열, 숫자, 불리언) + // Handle primitive types such as string, number, and boolean. const type = typeof value if (this.generators[type]) { return this.generators[type].generateSchema(value, includeExample) } - // 알 수 없는 타입인 경우 + // Fallback for unknown types. return { type: "string" } } } diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 0cdf3c5e..8b4ae508 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -19,18 +19,34 @@ import { DSLField } from "../interface" import { ResponseBuilder } from "./ResponseBuilder" import { FIELD_TYPES } from "../interface/field" import { AbstractTestBuilder } from "./AbstractTestBuilder" +import logger from "../../config/logger" /** * Builder class for setting API request information. */ export class RequestBuilder extends AbstractTestBuilder { /** - * Sets headers to be used in requests. + * Sets headers to be used in requests. Header names are normalized to lowercase. * @param {Record>} headers Headers to be used in requests * @returns {this} Request builder instance */ public header(headers: Record>): this { - this.config.requestHeaders = headers + const normalizedHeaders: Record> = {} + const seen = new Set() + + Object.entries(headers).forEach(([headerName, headerValue]) => { + const normalized = headerName.toLowerCase() + + if (seen.has(normalized)) { + logger.warn(`Duplicate header detected: "${headerName}" (already set)`) + return + } + + seen.add(normalized) + normalizedHeaders[normalized] = headerValue + }) + + this.config.requestHeaders = normalizedHeaders return this } diff --git a/lib/dsl/test-builders/ResponseBuilder.ts b/lib/dsl/test-builders/ResponseBuilder.ts index 75f3a173..02532e77 100644 --- a/lib/dsl/test-builders/ResponseBuilder.ts +++ b/lib/dsl/test-builders/ResponseBuilder.ts @@ -178,7 +178,7 @@ export class ResponseBuilder extends AbstractTestBuilder { }, response: { status: res.status, - body: this.config.expectedResponseBody || res.body, // 검증을 위한 예상 응답 본문을 우선으로 사용 + body: this.config.expectedResponseBody || res.body, // Prefer the expected response body for validation. headers: res.headers, }, testSuiteDescription: testContext.get() || "", diff --git a/lib/dsl/test-builders/validateResponse.ts b/lib/dsl/test-builders/validateResponse.ts index 60d3a726..77055317 100644 --- a/lib/dsl/test-builders/validateResponse.ts +++ b/lib/dsl/test-builders/validateResponse.ts @@ -25,18 +25,21 @@ import { isDSLField } from "../interface/field" * @see {@link import('../interface/field.ts').field} */ const validateDSLField = (expectedDSL: any, actualVal: any, path: string): void => { - // DSL Field의 example이 함수인 경우 + const example = expectedDSL.example + if (example === undefined) { + throw new Error( + `The example value of the DSL field at response body[${path}] is undefined. Skipping validation for this field.`, + ) + } + + // Handle a DSL field whose example is a function. if (typeof expectedDSL.example === "function") { - expectedDSL.example(actualVal) + validateFunction(expectedDSL.example, actualVal) return } - // DSL Field의 example이 객체인 경우 - if ( - expectedDSL.example && - typeof expectedDSL.example === "object" && - expectedDSL.example !== null - ) { + // Handle a DSL field whose example is an object. + if (example && typeof example === "object") { if (isDSLField(actualVal)) { validateResponse(expectedDSL.example, actualVal.example, path) } else { @@ -45,7 +48,7 @@ const validateDSLField = (expectedDSL: any, actualVal: any, path: string): void return } - // DSL Field의 example이 원시값인 경우 + // Handle a DSL field whose example is a primitive value. if (isDSLField(actualVal)) { if (actualVal.example !== expectedDSL.example) { throw new Error( @@ -80,6 +83,20 @@ const validateArray = (expectedArr: any[], actualArr: any[], path: string): void }) } +const validateFunction = (func: (actualValue: any) => any, actualVal: any): void => { + const argsCount = func.length + if (argsCount > 1) { + throw new Error( + `Validator function should have at most one argument, but got ${argsCount}. + Please check the following function: + + ${func.toString()}`, + ) + } + + func(actualVal) +} + /** * Function that performs **actual validation** of API response values defined in `ResponseBuilder`. * Performs validation by branching for various types such as arrays, objects, etc. @@ -90,14 +107,14 @@ const validateArray = (expectedArr: any[], actualArr: any[], path: string): void * @see {ResponseBuilder} */ export const validateResponse = (expected: any, actual: any, path: string = ""): void => { - // 배열인 경우 + // Handle array comparisons. if (Array.isArray(expected)) { validateArray(expected, actual, path) return } - // 객체인 경우 (null 제외) - if (expected && typeof expected === "object" && expected !== null) { + // Handle objects except for null. + if (expected && typeof expected === "object") { for (const key in expected) { const currentPath = path ? `${path}.${key}` : key const expectedVal = expected[key] @@ -107,8 +124,10 @@ export const validateResponse = (expected: any, actual: any, path: string = ""): validateDSLField(expectedVal, actualVal, currentPath) } else if (Array.isArray(expectedVal)) { validateArray(expectedVal, actualVal, currentPath) - } else if (expectedVal && typeof expectedVal === "object" && expectedVal !== null) { - if (!actualVal || typeof actualVal !== "object" || actualVal === null) { + } else if (typeof expectedVal === "function") { + validateFunction(expectedVal, actualVal) + } else if (expectedVal && typeof expectedVal === "object") { + if (!actualVal || typeof actualVal !== "object") { throw new Error( `Expected response body[${currentPath}] to be an object but got ${actualVal}`, ) @@ -123,7 +142,7 @@ export const validateResponse = (expected: any, actual: any, path: string = ""): return } - // 원시 타입인 경우 직접 비교 + // Compare primitive types directly. if (actual !== expected) { throw new Error(`Expected response body[${path}] to be ${expected} but got ${actual}`) } diff --git a/package.json b/package.json index f6488afc..1b7f7ba8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "itdoc", - "version": "0.4.0", + "version": "0.4.1", "description": "Test-driven documentation for RESTful services", "license": "Apache-2.0", "bin": { @@ -92,7 +92,8 @@ "lodash": "^4.17.21", "openai": "^4.90.0", "supertest": "^7.0.0", - "widdershins": "^4.0.1" + "widdershins": "^4.0.1", + "sinon": "^20.0.0" }, "devDependencies": { "@eslint/js": "~9.17", @@ -126,7 +127,7 @@ "jest": "^29.0.0", "mocha": "^11.0.0" }, - "packageManager": "pnpm@10.5.2", + "packageManager": "pnpm@10.15.0", "engines": { "node": ">=20" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 870fd13f..78d016a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: dependencies: '@redocly/cli': specifier: ^1.34.0 - version: 1.34.0(ajv@5.5.2)(supports-color@10.0.0) + version: 1.34.0(ajv@8.17.1)(supports-color@10.0.0) '@redocly/openapi-core': specifier: ^1.34.2 version: 1.34.2(supports-color@10.0.0) @@ -47,12 +47,15 @@ importers: openai: specifier: ^4.90.0 version: 4.90.0(ws@8.18.1) + sinon: + specifier: ^20.0.0 + version: 20.0.0 supertest: specifier: ^7.0.0 version: 7.0.0(supports-color@10.0.0) widdershins: specifier: ^4.0.1 - version: 4.0.1(ajv@5.5.2)(mkdirp@0.5.6)(supports-color@10.0.0) + version: 4.0.1(ajv@8.17.1)(mkdirp@0.5.6)(supports-color@10.0.0) devDependencies: '@eslint/js': specifier: ~9.17 @@ -111,9 +114,6 @@ importers: rimraf: specifier: ^6.0.1 version: 6.0.1 - sinon: - specifier: ^20.0.0 - version: 20.0.0 sort-package-json: specifier: ^2.15.1 version: 2.15.1 @@ -159,7 +159,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) + version: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) jest-diff: specifier: ^29.7.0 version: 29.7.0 @@ -233,7 +233,7 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) examples/nestjs: dependencies: @@ -330,13 +330,13 @@ importers: version: link:../.. jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) + version: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) mocha: specifier: ^10.3.0 version: 10.8.2 ts-jest: specifier: ^29.2.6 - version: 29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)))(typescript@5.8.3) + version: 29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)))(typescript@5.7.3) itdoc-doc: dependencies: @@ -357,7 +357,7 @@ importers: version: 2.1.1 docusaurus: specifier: ^1.14.7 - version: 1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + version: 1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) prism-react-renderer: specifier: ^2.3.0 version: 2.4.1(react@19.0.0) @@ -10974,10 +10974,12 @@ packages: superagent@9.0.2: resolution: {integrity: sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@7.0.0: resolution: {integrity: sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==} engines: {node: '>=14.18.0'} + deprecated: Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net supertest@7.1.4: resolution: {integrity: sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==} @@ -13592,7 +13594,7 @@ snapshots: react-dev-utils: 12.0.1(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) terser-webpack-plugin: 5.3.14(@swc/core@1.11.29)(esbuild@0.25.0)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) webpackbar: 6.0.1(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) transitivePeerDependencies: @@ -13717,7 +13719,7 @@ snapshots: tslib: 2.8.1 unified: 11.0.5 unist-util-visit: 5.0.0 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) vfile: 6.0.3 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) transitivePeerDependencies: @@ -14300,7 +14302,7 @@ snapshots: resolve-pathname: 3.0.0 shelljs: 0.8.5 tslib: 2.8.1 - url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) utility-types: 3.11.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) transitivePeerDependencies: @@ -14724,41 +14726,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(supports-color@10.0.0) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - '@types/node': 20.17.24 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0(supports-color@10.0.0) - jest-runner: 29.7.0(supports-color@10.0.0) - jest-runtime: 29.7.0(supports-color@10.0.0) - jest-snapshot: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3))': dependencies: '@jest/console': 29.7.0 @@ -14794,41 +14761,6 @@ snapshots: - supports-color - ts-node - '@jest/core@29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3))': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(supports-color@10.0.0) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - '@types/node': 20.17.24 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.9.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0(supports-color@10.0.0) - jest-runner: 29.7.0(supports-color@10.0.0) - jest-runtime: 29.7.0(supports-color@10.0.0) - jest-snapshot: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.8 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3))': dependencies: '@jest/console': 30.0.5 @@ -15544,7 +15476,7 @@ snapshots: require-from-string: 2.0.2 uri-js-replace: 1.0.1 - '@redocly/cli@1.34.0(ajv@5.5.2)(supports-color@10.0.0)': + '@redocly/cli@1.34.0(ajv@8.17.1)(supports-color@10.0.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/exporter-trace-otlp-http': 0.53.0(@opentelemetry/api@1.9.0) @@ -15553,7 +15485,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.27.0 '@redocly/config': 0.22.1 '@redocly/openapi-core': 1.34.0(supports-color@10.0.0) - '@redocly/respect-core': 1.34.0(ajv@5.5.2)(supports-color@10.0.0) + '@redocly/respect-core': 1.34.0(ajv@8.17.1)(supports-color@10.0.0) abort-controller: 3.0.0 chokidar: 3.6.0 colorette: 1.4.0 @@ -15610,12 +15542,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@redocly/respect-core@1.34.0(ajv@5.5.2)(supports-color@10.0.0)': + '@redocly/respect-core@1.34.0(ajv@8.17.1)(supports-color@10.0.0)': dependencies: '@faker-js/faker': 7.6.0 '@redocly/ajv': 8.11.2 '@redocly/openapi-core': 1.34.0(supports-color@10.0.0) - better-ajv-errors: 1.2.0(ajv@5.5.2) + better-ajv-errors: 1.2.0(ajv@8.17.1) colorette: 2.0.20 concat-stream: 2.0.0 cookie: 0.7.2 @@ -17177,11 +17109,22 @@ snapshots: jsonpointer: 4.1.0 leven: 3.1.0 - better-ajv-errors@1.2.0(ajv@5.5.2): + better-ajv-errors@0.6.7(ajv@8.17.1): + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/runtime': 7.27.0 + ajv: 8.17.1 + chalk: 2.4.2 + core-js: 3.41.0 + json-to-ast: 2.1.0 + jsonpointer: 4.1.0 + leven: 3.1.0 + + better-ajv-errors@1.2.0(ajv@8.17.1): dependencies: '@babel/code-frame': 7.26.2 '@humanwhocodes/momoa': 2.0.4 - ajv: 5.5.2 + ajv: 8.17.1 chalk: 4.1.2 jsonpointer: 5.0.1 leven: 3.1.0 @@ -17971,21 +17914,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/types': 29.6.3 @@ -18001,21 +17929,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-require@1.1.1: {} cross-env@7.0.3: @@ -18607,7 +18520,7 @@ snapshots: dependencies: '@leichtgewicht/ip-codec': 2.0.5 - docusaurus@1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + docusaurus@1.14.7(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) @@ -18647,7 +18560,7 @@ snapshots: postcss: 7.0.39 prismjs: 1.30.0 react: 16.14.0 - react-dev-utils: 11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + react-dev-utils: 11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) react-dom: 16.14.0(react@16.14.0) remarkable: 2.0.1 request: 2.88.2 @@ -19584,6 +19497,13 @@ snapshots: schema-utils: 3.3.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) + optional: true + file-type@10.11.0: {} file-type@19.6.0: @@ -19765,7 +19685,7 @@ snapshots: forever-agent@0.6.1: {} - fork-ts-checker-webpack-plugin@4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + fork-ts-checker-webpack-plugin@4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/code-frame': 7.26.2 chalk: 2.4.2 @@ -19774,7 +19694,7 @@ snapshots: semver: 5.7.2 tapable: 1.1.3 typescript: 5.6.3 - webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) worker-rpc: 0.1.1 optionalDependencies: eslint: 9.17.0(jiti@1.21.7)(supports-color@10.0.0) @@ -21277,25 +21197,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-cli@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) @@ -21315,25 +21216,6 @@ snapshots: - supports-color - ts-node - jest-cli@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - '@jest/test-result': 29.7.0 - '@jest/types': 29.6.3 - chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - exit: 0.1.2 - import-local: 3.2.0 - jest-config: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - jest-validate: 29.7.0 - yargs: 17.7.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest-cli@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@jest/core': 30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) @@ -21384,37 +21266,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.17.24 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) @@ -21446,37 +21297,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 20.17.24 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@babel/core': 7.26.9(supports-color@10.0.0) @@ -21508,37 +21328,6 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@babel/core': 7.26.9(supports-color@10.0.0) - '@jest/test-sequencer': 29.7.0 - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.26.9(supports-color@10.0.0))(supports-color@10.0.0) - chalk: 4.1.2 - ci-info: 3.9.0 - deepmerge: 4.3.1 - glob: 7.2.3 - graceful-fs: 4.2.11 - jest-circus: 29.7.0(supports-color@10.0.0) - jest-environment-node: 29.7.0 - jest-get-type: 29.6.3 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-runner: 29.7.0(supports-color@10.0.0) - jest-util: 29.7.0 - jest-validate: 29.7.0 - micromatch: 4.0.8 - parse-json: 5.2.0 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-json-comments: 3.1.1 - optionalDependencies: - '@types/node': 22.15.21 - ts-node: 10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3) - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - jest-config@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@babel/core': 7.28.0(supports-color@10.0.0) @@ -22020,18 +21809,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)): dependencies: '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3)) @@ -22044,18 +21821,6 @@ snapshots: - supports-color - ts-node - jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)): - dependencies: - '@jest/core': 29.7.0(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - '@jest/types': 29.6.3 - import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - jest@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)): dependencies: '@jest/core': 30.0.5(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)) @@ -24807,7 +24572,7 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-dev-utils@11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + react-dev-utils@11.0.4(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: '@babel/code-frame': 7.10.4 address: 1.1.2 @@ -24818,7 +24583,7 @@ snapshots: escape-string-regexp: 2.0.0 filesize: 6.1.0 find-up: 4.1.0 - fork-ts-checker-webpack-plugin: 4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + fork-ts-checker-webpack-plugin: 4.1.6(eslint@9.17.0(jiti@1.21.7)(supports-color@10.0.0))(supports-color@10.0.0)(typescript@5.6.3)(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) global-modules: 2.0.0 globby: 11.0.1 gzip-size: 5.1.1 @@ -24833,7 +24598,7 @@ snapshots: shell-quote: 1.7.2 strip-ansi: 6.0.0 text-table: 0.2.0 - webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) + webpack: 5.99.6(@swc/core@1.11.29)(esbuild@0.25.0) optionalDependencies: typescript: 5.6.3 transitivePeerDependencies: @@ -26329,9 +26094,9 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 - swagger2openapi@6.2.3(ajv@5.5.2): + swagger2openapi@6.2.3(ajv@8.17.1): dependencies: - better-ajv-errors: 0.6.7(ajv@5.5.2) + better-ajv-errors: 0.6.7(ajv@8.17.1) call-me-maybe: 1.0.2 node-fetch-h2: 2.3.0 node-readfiles: 0.2.0 @@ -26602,26 +26367,6 @@ snapshots: babel-jest: 29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0) esbuild: 0.25.0 - ts-jest@29.2.6(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@29.7.0(supports-color@10.0.0))(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest@29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)))(typescript@5.8.3): - dependencies: - bs-logger: 0.2.6 - ejs: 3.1.10 - fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@22.15.21)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3)) - jest-util: 29.7.0 - json5: 2.2.3 - lodash.memoize: 4.1.2 - make-error: 1.3.6 - semver: 7.7.1 - typescript: 5.8.3 - yargs-parser: 21.1.1 - optionalDependencies: - '@babel/core': 7.28.0(supports-color@10.0.0) - '@jest/transform': 29.7.0(supports-color@10.0.0) - '@jest/types': 29.6.3 - babel-jest: 29.7.0(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0) - esbuild: 0.25.0 - ts-jest@29.4.0(@babel/core@7.28.0(supports-color@10.0.0))(@jest/transform@30.0.5(supports-color@10.0.0))(@jest/types@30.0.5)(babel-jest@30.0.5(@babel/core@7.28.0(supports-color@10.0.0))(supports-color@10.0.0))(esbuild@0.25.0)(jest-util@30.0.5)(jest@30.0.5(@types/node@20.17.24)(supports-color@10.0.0)(ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.7.3)))(typescript@5.7.3): dependencies: bs-logger: 0.2.6 @@ -26674,27 +26419,6 @@ snapshots: '@swc/core': 1.11.29 optional: true - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@20.17.24)(typescript@5.8.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 20.17.24 - acorn: 8.14.1 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.8.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.11.29 - optional: true - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.7.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -26715,27 +26439,6 @@ snapshots: optionalDependencies: '@swc/core': 1.11.29 - ts-node@10.9.2(@swc/core@1.11.29)(@types/node@22.15.21)(typescript@5.8.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 22.15.21 - acorn: 8.14.1 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.8.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.11.29 - optional: true - tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 @@ -27065,14 +26768,14 @@ snapshots: urix@0.1.0: {} - url-loader@4.1.1(file-loader@6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): + url-loader@4.1.1(file-loader@6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)))(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.99.2(@swc/core@1.11.29)(esbuild@0.25.0) optionalDependencies: - file-loader: 6.2.0(webpack@5.99.2(@swc/core@1.11.29)(esbuild@0.25.0)) + file-loader: 6.2.0(webpack@5.99.6(@swc/core@1.11.29)(esbuild@0.25.0)) url-parse-lax@1.0.0: dependencies: @@ -27407,7 +27110,7 @@ snapshots: dependencies: isexe: 2.0.0 - widdershins@4.0.1(ajv@5.5.2)(mkdirp@0.5.6)(supports-color@10.0.0): + widdershins@4.0.1(ajv@8.17.1)(mkdirp@0.5.6)(supports-color@10.0.0): dependencies: dot: 1.1.3 fast-safe-stringify: 2.1.1 @@ -27421,7 +27124,7 @@ snapshots: oas-schema-walker: 1.1.5 openapi-sampler: 1.6.1 reftools: 1.1.9 - swagger2openapi: 6.2.3(ajv@5.5.2) + swagger2openapi: 6.2.3(ajv@8.17.1) urijs: 1.19.11 yaml: 1.10.2 yargs: 12.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5e01884b..0fabe662 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,10 +1,10 @@ packages: - # 모든 패키지 포함 + # Include every package. - "scripts/*" - "lib/**" - "examples/**" - "itdoc-doc/**" - # test 디렉토리 제외 + # Exclude test directories. - "!**/test/**" - # 빌드 출력 디렉토리 제외 + # Exclude build output directories. - "!**/dist/**" diff --git a/script/llm/examples/index.ts b/script/llm/examples/index.ts index 57c4737e..4f860b8f 100644 --- a/script/llm/examples/index.ts +++ b/script/llm/examples/index.ts @@ -1,38 +1,72 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + export const itdocExampleJs = ` describeAPI( HttpMethod.POST, "signup", { - summary: "회원 가입 API", + summary: "User Signup API", tag: "Auth", - description: "사용자로 부터 아이디와 패스워드를 받아 회원가입을 수행합니다.", + description: "Registers a user by receiving a username and password.", }, targetApp, (apiDoc) => { - itDoc("회원가입 성공", async () => { + itDoc("Sign up successfully", async () => { await apiDoc .test() .prettyPrint() .req() .body({ - username: field("사용자 이름", "username"), - password: field("패스워드", "P@ssw0rd123!@#"), + username: field("User name", "username"), + password: field("Password", "P@ssw0rd123!@#"), }) .res() .status(HttpStatus.CREATED) }) - itDoc("아이디를 입력하지 않으면 회원가입 실패한다.", async () => { + itDoc("Fail to sign up without a username", async () => { await apiDoc .test() .req() .body({ - password: field("패스워드", "P@ssw0rd123!@#"), + password: field("Password", "P@ssw0rd123!@#"), }) .res() .status(HttpStatus.BAD_REQUEST) .body({ - error: field("에러 메세지", "username is required"), + error: field("Error message", "username is required"), + }) + }) + itDoc("Return a 500 response when an error occurs", async () => { + const layer = getRouteLayer(targetApp, "post", "/signup") + sandbox.stub(layer, "handle").callsFake((req, res, next) => { + return res.status(500).json({ error: "Internal Server Error" }) + }) + await apiDoc + .test() + .req() + .body({ + username: field("User name", "hun"), + password: field("Password (8 characters minimum)", "12345678"), + }) + .res() + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body({ + error: field("Error message", "Internal Server Error"), }) }) }, @@ -42,28 +76,42 @@ describeAPI( HttpMethod.GET, "/users/:userId", { - summary: "사용자 조회 API", + summary: "User Lookup API", tag: "User", - description: "특정 사용자의 상세 정보를 조회하는 API입니다.", + description: "Retrieves detailed information for a specific user.", }, targetApp, (apiDoc) => { - itDoc("유효한 사용자 ID가 주어지면 200 응답을 반환한다.", async () => { + itDoc("Return 200 when a valid user ID is provided", async () => { await apiDoc .test() .req() .pathParam({ - userId: field("유효한 사용자 ID", "penek"), + userId: field("Valid user ID", "penek"), }) .res() .status(HttpStatus.OK) .body({ - userId: field("유저 ID", "penek"), - username: field("유저 이름", "hun"), - email: field("유저 이메일", "penekhun@gmail.com"), - friends: field("유저의 친구", ["zagabi", "json"]), + userId: field("User ID", "penek"), + username: field("User name", "hun"), + email: field("User email", "penekhun@gmail.com"), + friends: field("User friends", ["zagabi", "json"]), }) }) + + itDoc("Return 200 when valid headers are provided", async () => { + await apiDoc + .test() + .req() + .queryParam({ + token: field("Auth token A", 123456) + }) + .header({ + Authorization: field("Auth token B", "Bearer 123456"), + }) + .res() + .status(HttpStatus.OK) + }) }, ) ` diff --git a/script/llm/index.ts b/script/llm/index.ts index cbb91261..248380c0 100644 --- a/script/llm/index.ts +++ b/script/llm/index.ts @@ -19,214 +19,116 @@ import _ from "lodash" import fs from "fs" import path from "path" import dotenv from "dotenv" -import { getItdocPrompt, getMDPrompt } from "./prompt/index" +import { getItdocPrompt } from "./prompt/index" import logger from "../../lib/config/logger" import { loadFile } from "./loader/index" import { getOutputPath } from "../../lib/config/getOutputPath" import { analyzeRoutes } from "./parser/index" -import { parseSpecFile } from "../../lib/utils/specParser" -import { resolvePath } from "../../lib/utils/pathResolver" import { RouteResult } from "./parser/type/interface" /** - * Split raw Markdown into individual test blocks. - * Each block starts with an HTTP method line and includes subsequent bullet lines. - * @param {string} markdown - The raw Markdown string containing test definitions. - * @returns {string[]} Array of trimmed test block strings. + * Extracts a path prefix for grouping tests (first two non-empty segments). + * @param {string} pathStr - Full request path (e.g. "/api/products/123"). + * @returns {string} Normalized prefix (e.g. "/api/products"). Empty -> "/". */ -function splitTestBlocks(markdown: string): string[] { - const blockRegex = - /(?:^|\n)(?:-?\s*(?:GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+[^\n]+)(?:\n(?:- .+))*/g - const raw = markdown.match(blockRegex) || [] - return raw.map((b) => b.trim()) -} -/** - * Extract the API path prefix from a test block. - * Strips any leading dash, matches the HTTP method and path, then returns the top two segments. - * @param {string} mdBlock - A single test block string. - * @returns {string} The normalized prefix (e.g. "/api/products"). - */ -function getMarkdownPrefix(mdBlock: string): string { - const firstLine = mdBlock.split("\n")[0].trim().replace(/^-\s*/, "") // remove leading “- ” - const m = firstLine.match( - /^(?:테스트 이름:\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+([^\s]+)/i, - ) - if (!m) return "/unknown" - const path = m[2] - const parts = path.split("/").filter(Boolean) +function getPathPrefix(pathStr: string): string { + const parts = pathStr.split("/").filter(Boolean) return "/" + parts.slice(0, 2).join("/") } + /** - * Group test blocks by their path prefix and chunk each group into arrays of limited size. - * @param {string} markdown - The raw Markdown containing test blocks. - * @param {number} [chunkSize] - Maximum number of blocks per chunk. - * @returns {string[][]} Array of chunks, each a list of test block strings. + * Groups routes by prefix and chunks each group. + * @param {RouteResult[]} routes - Parsed route specs. + * @param {number} [chunkSize] - Max routes per chunk. + * @returns {RouteResult[][]} Chunked groups of routes. */ -function groupAndChunkMarkdownTests(markdown: string, chunkSize: number = 5): string[][] { - const blocks = splitTestBlocks(markdown) - const byPrefix: Record = {} - for (const blk of blocks) { - const prefix = getMarkdownPrefix(blk) - ;(byPrefix[prefix] ||= []).push(blk) +function groupAndChunkSpecRoutes(routes: RouteResult[], chunkSize: number = 10): RouteResult[][] { + const by: Record = {} + for (const r of routes) { + const prefix = getPathPrefix(r.path || "/unknown") + ;(by[prefix] ||= []).push(r) } - - const allChunks: string[][] = [] - for (const group of Object.values(byPrefix)) { - const chunks = _.chunk(group, chunkSize) - for (const c of chunks) { - allChunks.push(c) - } + const out: RouteResult[][] = [] + for (const group of Object.values(by)) { + for (const c of _.chunk(group, chunkSize)) out.push(c) } - - return allChunks + return out } + /** - * Convert grouped Markdown test definitions into itdoc-formatted TypeScript, - * calling the OpenAI API for each chunk. - * @param {OpenAI} openai - An initialized OpenAI client instance. - * @param {string} rawMarkdown - The raw Markdown test spec. - * @param {boolean} isEn - Whether to generate prompts/output in English. - * @param {boolean} [isTypeScript] - Whether output should use TypeScript syntax. - * @returns {Promise} The concatenated itdoc output or null on error. + * Creates itdoc test code from analyzed route JSON using an LLM. + * + * - Groups routes by prefix into chunks. + * - For each chunk, builds a prompt and calls OpenAI Chat Completions. + * - Concatenates all generated tests into a single string. + * @param {OpenAI} openai - OpenAI client. + * @param {RouteResult[]} raw - Array of analyzed route specs. + * @param {boolean} isEn - Output in English (true) or Korean (false). + * @param {boolean} [isTypeScript] - Generate TS-flavored examples. + * @returns {Promise} Generated test code or null on failure. */ -async function makeitdocByMD( +export async function makeitdoc( openai: OpenAI, - rawMarkdown: string, + raw: RouteResult[], isEn: boolean, isTypeScript: boolean = false, ): Promise { try { const maxRetry = 5 let result = "" - const chunks = groupAndChunkMarkdownTests(rawMarkdown, 5) + const specChunks = groupAndChunkSpecRoutes(raw) let gptCallCount = 0 - - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex] - const chunkContent = chunk.join("\n\n") + for (let chunkIndex = 0; chunkIndex < specChunks.length; chunkIndex++) { + const routesChunk = specChunks[chunkIndex] let chunkResult = "" + for (let retry = 0; retry < maxRetry; retry++) { gptCallCount++ - logger.info(`[makeitdocByMD] Attempting GPT call : ${gptCallCount} times`) - const msg = getItdocPrompt(chunkContent, isEn, retry + 1, isTypeScript) - const response: any = await openai.chat.completions.create({ - model: "gpt-4o", + logger.info(`[makeitdoc] Attempting GPT call : ${gptCallCount} times`) + const msg = getItdocPrompt(routesChunk, isEn, retry + 1, isTypeScript) + + const response = await openai.chat.completions.create({ + model: "gpt-5", messages: [{ role: "user", content: msg }], - temperature: 0, - max_tokens: 10000, + max_completion_tokens: 10000, }) - const text = response.choices[0].message.content?.trim() ?? "" - const finishReason = response.choices[0].finish_reason - const cleaned = text - .replace(/```(?:json|javascript|typescript|markdown)?/g, "") - .replace(/```/g, "") - .replace(/\(.*?\/.*?\)/g, "") - .trim() - chunkResult += cleaned + "\n" - if (finishReason === "stop") break - await new Promise((res) => setTimeout(res, 500)) - } - result += chunkResult.trim() + "\n\n" - await new Promise((res) => setTimeout(res, 500)) - } - return result.trim() - } catch (error: unknown) { - logger.error(`makeitdocByMD() ERROR: ${error}`) - return null - } -} -/** - * Extracts the top two segments of a URL path to use as a grouping prefix. - * @param {string} path - The full request path (e.g. "/api/products/123"). - * @returns {string} The normalized prefix (e.g. "/api/products"). - */ -function getPathPrefix(path: string): string { - const parts = path.split("/").filter(Boolean) - return "/" + parts.slice(0, 2).join("/") -} -/** - * Groups an array of route objects by their path prefix and then chunks each group. - * @param {{ path: string }[]} content - Array of route objects with a `path` property. - * @param {number} [chunkSize] - Maximum number of routes per chunk. Defaults to 5. - * @returns {any[][]} A list of route chunks, each chunk is an array of route objects. - */ -function groupAndChunkRoutes(content: any[], chunkSize: number = 5): any[][] { - const grouped = _.groupBy(content, (item: { path: string }) => getPathPrefix(item.path)) - const chunkedGroups: any[][] = [] - for (const groupItems of Object.values(grouped)) { - const chunks = _.chunk(groupItems, chunkSize) - chunkedGroups.push(...chunks) - } - return chunkedGroups -} -/** - * Generates a Markdown specification by batching routes into chunks and querying the LLM. - * @param {OpenAI} openai - An initialized OpenAI client. - * @param {any[]} content - Array of route definitions to generate spec for. - * @returns {Promise} The concatenated Markdown spec, or null if an error occurred. - */ + const choice = response.choices?.[0] + const text = choice?.message?.content?.trim() ?? "" + const finishReason = choice?.finish_reason ?? null -/** - * Generates a Markdown specification by analyzing app routes using OpenAI. - * @param {OpenAI} openai - An initialized OpenAI client instance. - * @param {RouteResult[]} content - Array of route definitions to generate spec for. - * @returns {Promise} The concatenated Markdown spec, or null if an error occurred. - */ -async function makeMDByApp(openai: OpenAI, content: RouteResult[]): Promise { - try { - let cnt = 0 - const chunks = groupAndChunkRoutes(content, 4) - const maxRetry = 5 - let result = "" - for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) { - const chunk = chunks[chunkIndex] - let chunkResult = "" - for (let retry = 0; retry < maxRetry; retry++) { - logger.info(`[makeMDByApp] Attempting GPT API call : ${++cnt} times`) - const msg = getMDPrompt(chunk, retry + 1) - const response: any = await openai.chat.completions.create({ - model: "gpt-4o", - messages: [{ role: "user", content: msg }], - temperature: 0, - max_tokens: 10000, - }) - const text = response.choices[0].message.content?.trim() ?? "" - const finishReason = response.choices[0].finish_reason const cleaned = text .replace(/```(?:json|javascript|typescript|markdown)?/g, "") .replace(/```/g, "") - .replace(/`markdown/g, "") - .replace(/\(.*?\/.*?\)/g, "") .trim() chunkResult += cleaned + "\n" - if (finishReason === "stop") break + if (finishReason === "stop" && cleaned) break await new Promise((res) => setTimeout(res, 500)) } + result += chunkResult.trim() + "\n\n" + await new Promise((res) => setTimeout(res, 300)) } return result.trim() } catch (error: unknown) { - logger.error(`makeMDByApp() ERROR: ${error}`) + logger.error(`makeitdoc() ERROR: ${error}`) return null } } + /** - * Main entry point to generate both Markdown specs and itdoc TypeScript tests. - * - If `testspecPath` is provided, reads and processes that file. - * - Otherwise analyzes an Express app's routes to build the spec. - * @param {string} [testspecPath] - Optional path to an existing Markdown test spec file. - * @param {string} [appPath] - Path to the Express app entry file (overrides spec metadata). - * @param {string} [envPath] - Path to the .env file containing OPENAI_API_KEY. - * @returns {Promise} Exits the process on error, otherwise writes output files. + * CLI entrypoint that: + * - Loads environment variables. + * - Analyzes an Express app file into route specs. + * - Invokes LLM to generate itdoc tests. + * - Writes the resulting test file with prelude (imports/helpers). + * @param {string} [appPath] - Path to Express app entry. + * @param {string} [envPath] - Path to .env file containing OPENAI_API_KEY. + * @returns {Promise} Exits the process on unrecoverable errors. */ -export default async function generateByLLM( - testspecPath?: string, - appPath?: string, - envPath?: string, -): Promise { +export default async function generateByLLM(appPath?: string, envPath?: string): Promise { const actualEnv = loadFile("env", envPath, false) dotenv.config({ path: actualEnv }) if (!process.env.OPENAI_API_KEY) { @@ -241,32 +143,6 @@ export default async function generateByLLM( let isTypeScript = false let appImportPath = "" let resolvedAppPath = "" - let parsedSpecContent = "" - const originalAppPath = appPath - - if (testspecPath && !originalAppPath) { - if (!fs.existsSync(testspecPath)) { - logger.error(`Test spec file not found: ${testspecPath}`) - process.exit(1) - } - - const specContent = fs.readFileSync(testspecPath, "utf8") - const { metadata, content } = parseSpecFile(specContent) - parsedSpecContent = content - - if (metadata.app) { - appPath = resolvePath(metadata.app) - logger.info(`[generateByLLM] App path found in : ${metadata.app} -> ${appPath}`) - } else { - logger.error(` - [generateByLLM] App path is not defined in the test spec file. Please define it at the top of the test spec file like below: - --- - app:@/path/to/your/app.js - --- - `) - process.exit(1) - } - } if (!appPath) { logger.error("App path not provided. Please specify it with -a or --app option.") @@ -279,49 +155,20 @@ export default async function generateByLLM( const relativePath = path.relative(outputDir, resolvedAppPath).replace(/\\/g, "/") appImportPath = relativePath.startsWith(".") ? relativePath : `./${relativePath}` - if (!testspecPath) { - let analyzedRoutes = await analyzeRoutes(resolvedAppPath) - - if (!analyzedRoutes) { - logger.error( - "AST analysis failed. Please ensure your routes use app.get() or router.post() format.", - ) - process.exit(1) - } - analyzedRoutes = analyzedRoutes.sort((a, b) => a.path.localeCompare(b.path)) - - const specFromApp = await makeMDByApp(openai, analyzedRoutes) - - if (!specFromApp) { - logger.error("Failed to generate markdown spec from app analysis.") - process.exit(1) - } - - const mdPath = path.join(outputDir, "output.md") - fs.writeFileSync(mdPath, specFromApp, "utf8") - logger.info(`Your APP Markdown spec analysis completed: ${mdPath}`) - - const doc = await makeitdocByMD(openai, specFromApp, false, isTypeScript) - if (!doc) { - logger.error("Failed to generate itdoc from markdown spec.") - process.exit(1) - } - result = doc - } else { - let specContent: string - if (parsedSpecContent) { - specContent = parsedSpecContent - } else { - specContent = loadFile("spec", testspecPath, true) - } + const analyzedRoutes = await analyzeRoutes(resolvedAppPath) + if (!analyzedRoutes) { + logger.error( + "AST analysis failed. Please ensure your routes use app.get() or router.post() format.", + ) + process.exit(1) + } - const doc = await makeitdocByMD(openai, specContent, false, isTypeScript) - if (!doc) { - logger.error("Failed to generate test code from markdown spec.") - process.exit(1) - } - result = doc + const doc = await makeitdoc(openai, analyzedRoutes, false, isTypeScript) + if (!doc) { + logger.error("Failed to generate itdoc from markdown spec.") + process.exit(1) } + result = doc if (!result) { logger.error("generateByLLM() did not return any result.") @@ -338,15 +185,48 @@ export default async function generateByLLM( let importStatement = "" if (isTypeScript) { importStatement = `import { app } from "${appImportPath}" -import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc"` +import { describeAPI, itDoc, HttpStatus, field, HttpMethod } from "itdoc" +import sinon from "sinon" +const sandbox = sinon.createSandbox() +function getRouteLayer(app, method, path) { + method = String(method).toLowerCase() + const stack = app && app._router && app._router.stack ? app._router.stack : [] + for (const layer of stack) { + if (!layer.route) continue + if (layer.route.path !== path) continue + if (!layer.route.methods || !layer.route.methods[method]) continue + const routeStack = layer.route.stack || [] + if (routeStack.length > 0) return routeStack[0] + } +} +afterEach(() => { + sandbox.restore() +}) +` } else { importStatement = `const app = require('${appImportPath}') const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") const targetApp = app +const sinon = require("sinon") +const sandbox = sinon.createSandbox() +function getRouteLayer(app, method, path) { + method = String(method).toLowerCase() + const stack = app && app._router && app._router.stack ? app._router.stack : [] + for (const layer of stack) { + if (!layer.route) continue + if (layer.route.path !== path) continue + if (!layer.route.methods || !layer.route.methods[method]) continue + const routeStack = layer.route.stack || [] + if (routeStack.length > 0) return routeStack[0] + } +} +afterEach(() => { + sandbox.restore() +}) ` } - result = importStatement + "\n\n" + result.trim() + result = importStatement + "\n\n" + result.trim() fs.writeFileSync(outPath, result, "utf8") logger.info(`[generateByLLM] itdoc LLM SCRIPT completed.`) } diff --git a/script/llm/loader/index.ts b/script/llm/loader/index.ts index 412ed21c..f044ea5a 100644 --- a/script/llm/loader/index.ts +++ b/script/llm/loader/index.ts @@ -18,18 +18,31 @@ import fs from "fs" import path from "path" import logger from "../../../lib/config/logger" -type FileType = "spec" | "app" | "env" +type FileType = "app" | "env" /** - * Checks the path according to the given type and returns the file path or content. - * @param {FileType} type "spec" | "app" | "env" - * @param {string} filePath Input path (relative or absolute) - * @param {boolean} readContent If true, returns file content as string; if false, returns only the path - * @returns {string} (file content or path) + * Load and optionally read a file depending on its {@link FileType}. + * + * Behavior by type: + * - **`"app"`** (required): If the file cannot be resolved, logs an error and **terminates the process** with `process.exit(1)`. + * - **`"env"`** (optional): If the file cannot be resolved, logs a warning and returns an empty string. + * + * Resolution rules: + * - If `filePath` is provided, it is resolved relative to `process.cwd()` when not absolute. + * - If `filePath` is omitted, the function searches a set of sensible defaults for the given type. + * @param {FileType} type + * The category of file to resolve (`"app"` or `"env"`). + * @param {string} [filePath] + * An explicit path to the file. If relative, it is resolved from `process.cwd()`. + * When omitted, a default search list is used (see implementation). + * @param {boolean} [readContent] + * When `true`, returns the UTF-8 file contents; when `false`, returns the resolved absolute path. + * @returns {string} + * The resolved absolute path (when `readContent === false`) or the UTF-8 contents (when `true`). + * For missing `"env"` files, an empty string is returned. */ export function loadFile(type: FileType, filePath?: string, readContent: boolean = false): string { - const defaultPaths: Record = { - spec: [path.resolve(process.cwd(), "md/testspec.md")], + const defaults: Record = { app: [ path.resolve(process.cwd(), "app.js"), path.resolve(process.cwd(), "app.ts"), @@ -41,26 +54,41 @@ export function loadFile(type: FileType, filePath?: string, readContent: boolean env: [path.resolve(process.cwd(), ".env")], } - let resolvedPath: string - + let resolved: string | undefined if (filePath) { - resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath) + resolved = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath) } else { - const foundPath = defaultPaths[type].find((p) => fs.existsSync(p)) - - if (!foundPath) { - logger.error( - `${type} 파일을 찾을 수 없습니다. 기본 경로: ${defaultPaths[type].join(", ")}`, - ) - process.exit(1) + resolved = defaults[type].find((p) => fs.existsSync(p)) + } + if (!resolved || !fs.existsSync(resolved)) { + if (type === "env") { + if (filePath) { + logger.warn( + `ENV file not found at provided path: ${filePath}. Continuing without it.`, + ) + } else { + logger.warn( + `ENV file not found at default locations: ${defaults.env.join(", ")}. Continuing without it.`, + ) + } + return readContent ? "" : "" } - - resolvedPath = foundPath + if (filePath) { + logger.error(`${type} file does not exist: ${filePath}`) + } else { + logger.error(`${type} file not found. Searched: ${defaults[type].join(", ")}`) + } + process.exit(1) } + if (!readContent) return resolved - if (!fs.existsSync(resolvedPath)) { - logger.error(`${type} 파일이 존재하지 않습니다: ${resolvedPath}`) + try { + return fs.readFileSync(resolved, "utf8") + } catch (err) { + logger.error(`Failed to read ${type} file: ${resolved}. ${(err as Error).message}`) + if (type === "env") { + return "" + } process.exit(1) } - return readContent ? fs.readFileSync(resolvedPath, "utf8") : resolvedPath } diff --git a/script/llm/parser/analyzer/returnValueExtractor.ts b/script/llm/parser/analyzer/returnValueExtractor.ts deleted file mode 100644 index c8a0c7b8..00000000 --- a/script/llm/parser/analyzer/returnValueExtractor.ts +++ /dev/null @@ -1,194 +0,0 @@ -/* - * Copyright 2025 the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NodePath } from "@babel/traverse" -import * as t from "@babel/types" -import { getProjectFiles, parseMultipleFiles } from "../utils/fileParser" -import { extractValue } from "../utils/extractValue" -import traversePkg from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules 호환성 이슈로 인한 타입 에러 무시 -const traverse = traversePkg.default - -/** - * Dynamically finds related service methods across the project and extracts actual return values. - * @param {string} methodName - Method name to find (e.g., getAllProducts) - * @param {string} projectRoot - Project root path - * @returns {any} Extracted actual return value or null - */ -export function extractActualReturnValue(methodName: string, projectRoot: string): any { - try { - const filePaths = getProjectFiles(projectRoot) - const parsedFiles = parseMultipleFiles(filePaths) - - for (const { ast } of parsedFiles) { - const result = extractReturnValueFromAST(ast, methodName) - - if (result && !hasPartialNullValues(result)) { - return result - } - } - - return null - } catch { - return null - } -} - -/** - * Checks if an object contains null values. - * @param {any} obj - Object to check - * @returns {boolean} Whether null values are included - */ -export function hasPartialNullValues(obj: any): boolean { - if (obj === null || obj === undefined) return true - if (typeof obj !== "object") return false - if (Array.isArray(obj)) { - return obj.some((item) => hasPartialNullValues(item)) - } - return Object.values(obj).some((value) => hasPartialNullValues(value)) -} - -/** - * Dynamically extracts return values of specific methods from AST. - * @param {t.File} ast - File AST - * @param {string} methodName - Method name - * @returns {any} Return value structure - */ -export function extractReturnValueFromAST(ast: t.File, methodName: string): any { - let returnValue: any = null - - traverse(ast, { - ClassMethod(methodPath: NodePath) { - if (t.isIdentifier(methodPath.node.key) && methodPath.node.key.name === methodName) { - returnValue = extractReturnFromFunction(methodPath.node, ast) - } - }, - ObjectMethod(methodPath: NodePath) { - if (t.isIdentifier(methodPath.node.key) && methodPath.node.key.name === methodName) { - returnValue = extractReturnFromFunction(methodPath.node, ast) - } - }, - VariableDeclarator(varPath: NodePath) { - if (t.isObjectExpression(varPath.node.init)) { - const objExpr = varPath.node.init - objExpr.properties.forEach((prop) => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { - if (prop.key.name === methodName) { - if ( - t.isArrowFunctionExpression(prop.value) || - t.isFunctionExpression(prop.value) - ) { - returnValue = extractReturnFromFunction(prop.value, ast) - } - } - } - }) - } else if ( - t.isIdentifier(varPath.node.id) && - varPath.node.id.name === methodName && - (t.isArrowFunctionExpression(varPath.node.init) || - t.isFunctionExpression(varPath.node.init)) - ) { - returnValue = extractReturnFromFunction(varPath.node.init, ast) - } - }, - FunctionDeclaration(funcPath: NodePath) { - if (funcPath.node.id && funcPath.node.id.name === methodName) { - returnValue = extractReturnFromFunction(funcPath.node, ast) - } - }, - }) - - return returnValue -} - -/** - * Extracts return structure from function/method - * @param {t.Function} func - Function node - * @param {t.File} ast - Complete file AST (for variable finding) - * @returns {any} Return value structure - */ -export function extractReturnFromFunction(func: t.Function, ast?: t.File): any { - let returnValue: any = null - const visitedVariables = new Set() - - /** - * Recursively traverses nodes to find all ReturnStatements. - * @param {t.Node} node Node to traverse - * @returns {t.ReturnStatement[]} All ReturnStatements - */ - function findAllReturnStatements(node: t.Node): t.ReturnStatement[] { - const returns: t.ReturnStatement[] = [] - - if (t.isReturnStatement(node)) { - returns.push(node) - } - - if (t.isBlockStatement(node)) { - for (const stmt of node.body) { - returns.push(...findAllReturnStatements(stmt)) - } - } - - if (t.isIfStatement(node)) { - returns.push(...findAllReturnStatements(node.consequent)) - if (node.alternate) { - returns.push(...findAllReturnStatements(node.alternate)) - } - } - - return returns - } - - /** - * Selects the most meaningful ReturnStatement. - * Prioritizes actual values over undefined or null. - * @param {t.ReturnStatement[]} returnStatements Return statements - * @returns {t.ReturnStatement | null} The most meaningful ReturnStatement - */ - function selectBestReturnStatement( - returnStatements: t.ReturnStatement[], - ): t.ReturnStatement | null { - if (returnStatements.length === 0) return null - - for (const stmt of returnStatements) { - if (stmt.argument) { - if (t.isIdentifier(stmt.argument)) { - if (stmt.argument.name !== "undefined" && stmt.argument.name !== "null") { - return stmt - } - } else { - return stmt - } - } - } - - return returnStatements[0] - } - - if (func.body && t.isBlockStatement(func.body)) { - const allReturns = findAllReturnStatements(func.body) - const returnStmt = selectBestReturnStatement(allReturns) - - if (returnStmt?.argument) { - returnValue = extractValue(returnStmt.argument, {}, {}, ast, visitedVariables) - } - } else if (func.body && t.isExpression(func.body)) { - returnValue = extractValue(func.body, {}, {}, ast, visitedVariables) - } - - return returnValue -} diff --git a/script/llm/parser/analyzer/routeAnalyzer.ts b/script/llm/parser/analyzer/routeAnalyzer.ts index bbb370d2..befd586c 100644 --- a/script/llm/parser/analyzer/routeAnalyzer.ts +++ b/script/llm/parser/analyzer/routeAnalyzer.ts @@ -15,7 +15,7 @@ */ import traversePkg, { NodePath } from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules 호환성 이슈로 인한 타입 에러 무시 +// @ts-expect-error - Ignore the type error caused by a CommonJS/ES module compatibility issue. const traverse = traversePkg.default import * as t from "@babel/types" import * as path from "path" @@ -159,14 +159,31 @@ function findFunctionDefinition( return foundFunction } - /** - * Analyzes the body of a given function node to collect request/response information. - * @param {t.FunctionExpression | t.ArrowFunctionExpression} functionNode - The function node to analyze. - * @param {string} source - The source code of the file. - * @param {any} ret - The object that collects analysis results. - * @param {NodePath} parentPath - The parent call expression node. - * @param {t.File} [ast] - The full AST of the file, used for nested function analysis. + * Analyze the body of a route handler function to extract request/response metadata. + * + * Walks the handler’s AST and delegates to specialized analyzers: + * - **VariableDeclarator** → `analyzeVariableDeclarator` + * Captures destructured `req` fields (`query`, `params`, `headers`, `body`), tracks identifiers, + * and records samples for local array literals (e.g., `const members = [...]`). + * - **CallExpression** → `analyzeResponseCall` + * Detects `res.status(...)`, `res.json(...)`, `res.send(...)`, and aggregates default/branch responses. + * - **MemberExpression** → `analyzeMemberExpression` + * Tracks usage like `req.headers.*`, `req.body.*`, and analyzes inline `res.json({ ... })` objects. + * + * The function **mutates** the provided accumulator `ret` in place (adds req field sets, response maps, etc.). + * @param {t.FunctionExpression | t.ArrowFunctionExpression} functionNode + * The route handler (function or arrow function) whose body will be traversed. + * @param {string} source + * Raw source code of the file; forwarded to sub-analyzers for context (e.g., snippet extraction). + * @param {any} ret + * Mutable accumulator object that will be enriched with analysis results + * (e.g., `reqHeaders`, `reqParams`, `reqQuery`, `bodyFields`, `defaultResponse`, `branchResponses`). + * @param {NodePath} parentPath + * The `CallExpression` path that registered the route (e.g., `app.get(...)`), used to inherit scope during traversal. + * @param {t.File} [ast] + * Optional full-file AST. When provided, sub-analyzers may use it to resolve identifiers across the file. + * @returns {void} Mutates `ret` in place; no value is returned. */ export function analyzeFunctionBody( functionNode: t.FunctionExpression | t.ArrowFunctionExpression, diff --git a/script/llm/parser/analyzer/variableAnalyzer.ts b/script/llm/parser/analyzer/variableAnalyzer.ts index fceb9757..6e219b37 100644 --- a/script/llm/parser/analyzer/variableAnalyzer.ts +++ b/script/llm/parser/analyzer/variableAnalyzer.ts @@ -16,13 +16,60 @@ import { NodePath } from "@babel/traverse" import * as t from "@babel/types" -import { createFunctionIdentifier } from "../utils/extractValue" +import { extractValue } from "../utils/extractValue" /** - * Analyzes request parameters and function call results from variable declarations. - * @param {NodePath} varPath - Variable declaration node - * @param {any} ret - Analysis result storage object - * @param {Record} localArrays - Local array storage object + * Ensures that the accumulator object `ret` has a response json field map. + * @param {any} ret Mutable accumulator for analysis results. + * @returns {Record} The ensured `responseJsonFieldMap`. + */ +function ensureResMap(ret: any) { + if (!ret.responseJsonFieldMap) ret.responseJsonFieldMap = {} + return ret.responseJsonFieldMap as Record +} + +/** + * Analyze `res.json({ ... })` object literal and fill metadata for each property. + * @param {t.ObjectExpression} obj Object literal passed to `res.json`. + * @param {any} ret Mutable accumulator for analysis results (augments `responseJsonFieldMap`). + * @param {Record | undefined} variableMap Mapping of identifiers to previously-resolved descriptors. + * @param {Record} localArrays Map of local array identifiers (for value tracking). + * @param {t.File | undefined} ast Whole-file AST (optional). + * @returns {void} + */ +function analyzeJsonObjectFields( + obj: t.ObjectExpression, + ret: any, + variableMap: Record | undefined, + localArrays: Record, + ast?: t.File, +) { + const out = ensureResMap(ret) + + for (const p of obj.properties) { + if (!t.isObjectProperty(p)) continue + + let keyName: string | null = null + if (t.isIdentifier(p.key)) keyName = p.key.name + else if (t.isStringLiteral(p.key)) keyName = p.key.value + if (!keyName) continue + + const v = p.value + const extracted = extractValue(v as t.Node, localArrays, variableMap ?? {}, ast) + if (extracted !== null && extracted !== undefined) { + out[keyName] = extracted + } + } +} + +/** + * Analyze a variable declarator for: + * - Calls/constructors assigned to identifiers + * - Destructuring from `req.query|params|body|headers` and track field usage + * @param {NodePath} varPath Variable declarator path. + * @param {any} ret Mutable accumulator for analysis results (adds `variableMap`, `req*` sets). + * @param {Record} localArrays Map of local array identifiers (for value tracking). + * @returns {void} */ export function analyzeVariableDeclarator( varPath: NodePath, @@ -32,24 +79,27 @@ export function analyzeVariableDeclarator( const decl = varPath.node if (t.isIdentifier(decl.id) && t.isArrayExpression(decl.init)) { - localArrays[decl.id.name] = [] + const arrVal = extractValue( + decl.init, + localArrays, + ret.variableMap ?? {}, + ret.ast as t.File | undefined, + ) + localArrays[decl.id.name] = Array.isArray(arrVal) ? arrVal : [] return } if (t.isIdentifier(decl.id) && decl.init) { - let callExpression: t.CallExpression | null = null + if (!ret.variableMap) ret.variableMap = {} + const maybe = extractValue( + decl.init as t.Node, + localArrays, + ret.variableMap, + ret.ast as t.File | undefined, + ) - if (t.isAwaitExpression(decl.init) && t.isCallExpression(decl.init.argument)) { - callExpression = decl.init.argument - } else if (t.isCallExpression(decl.init)) { - callExpression = decl.init - } - - if (callExpression) { - if (!ret.variableMap) { - ret.variableMap = {} - } - ret.variableMap[decl.id.name] = createFunctionIdentifier(callExpression) + if (maybe !== null && maybe !== undefined) { + ret.variableMap[decl.id.name] = maybe } } @@ -57,25 +107,62 @@ export function analyzeVariableDeclarator( t.isObjectPattern(decl.id) && t.isMemberExpression(decl.init) && t.isIdentifier(decl.init.object, { name: "req" }) && - t.isIdentifier(decl.init.property) + (t.isIdentifier(decl.init.property) || t.isStringLiteral(decl.init.property)) ) { - const propName = decl.init.property.name + const propName = t.isIdentifier(decl.init.property) + ? decl.init.property.name + : decl.init.property.value + decl.id.properties.forEach((prop: any) => { - if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) { - const fieldName = prop.key.name + if (t.isObjectProperty(prop)) { + const key = prop.key + const fieldName = t.isIdentifier(key) + ? key.name + : t.isStringLiteral(key) + ? key.value + : null + if (!fieldName) return switch (propName) { case "query": ret.reqQuery.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.query", + property: fieldName, + identifier: `req.query.${fieldName}`, + } break case "params": ret.reqParams.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.params", + property: fieldName, + identifier: `req.params.${fieldName}`, + } break case "headers": ret.reqHeaders.add(fieldName.toLowerCase()) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.headers", + property: fieldName.toLowerCase(), + identifier: `req.headers.${fieldName.toLowerCase()}`, + } break case "body": ret.bodyFields.add(fieldName) + if (!ret.variableMap) ret.variableMap = {} + ret.variableMap[fieldName] = { + type: "member_access", + object: "req.body", + property: fieldName, + identifier: `req.body.${fieldName}`, + } break } } @@ -84,21 +171,37 @@ export function analyzeVariableDeclarator( } /** - * Analyzes request parameters from member expressions. - * @param {NodePath} memPath - Member expression node - * @param {any} ret - Analysis result storage object + * Inspect member-expressions to: + * - Track request field usage (`req.headers.*`, `req.body.*`) + * - Detect `res.json({ ... })` calls and analyze their object argument + * @param {NodePath} memPath Member expression path. + * @param {any} ret Mutable accumulator for analysis results. + * @returns {void} */ export function analyzeMemberExpression(memPath: NodePath, ret: any) { const mm = memPath.node + const parentObj = ((): t.MemberExpression | t.OptionalMemberExpression | null => { + if (t.isMemberExpression(mm.object) || t.isOptionalMemberExpression(mm.object)) { + return mm.object + } + return null + })() + if ( - t.isMemberExpression(mm.object) && - t.isIdentifier(mm.object.object, { name: "req" }) && - t.isIdentifier(mm.object.property) && - t.isIdentifier(mm.property) + parentObj && + t.isIdentifier(parentObj.object, { name: "req" }) && + (t.isIdentifier(parentObj.property) || t.isStringLiteral(parentObj.property)) ) { - const parent = mm.object.property.name - const child = mm.property.name + const parent = t.isIdentifier(parentObj.property) + ? parentObj.property.name + : parentObj.property.value + + let child: string | null = null + if (t.isIdentifier(mm.property)) child = mm.property.name + else if (t.isStringLiteral(mm.property)) child = mm.property.value + + if (!child) return if (parent === "headers") { ret.reqHeaders.add(child.toLowerCase()) @@ -106,4 +209,32 @@ export function analyzeMemberExpression(memPath: NodePath, r ret.bodyFields.add(child) } } + + const isJsonCallee = + (t.isIdentifier(mm.property, { name: "json" }) || + t.isStringLiteral(mm.property, { value: "json" })) && + (t.isIdentifier(mm.object, { name: "res" }) || + (t.isCallExpression(mm.object) && + (t.isMemberExpression(mm.object.callee) || + t.isOptionalMemberExpression(mm.object.callee)) && + (t.isIdentifier((mm.object.callee as t.MemberExpression).property, { + name: "status", + }) || + t.isStringLiteral((mm.object.callee as t.MemberExpression).property as any, { + value: "status", + })))) + + if (isJsonCallee && memPath.parentPath && memPath.parentPath.isCallExpression()) { + const call = memPath.parentPath.node + const firstArg = call.arguments[0] + if (firstArg && t.isObjectExpression(firstArg)) { + analyzeJsonObjectFields( + firstArg, + ret, + ret.variableMap, + ret.localArrays ?? {}, + ret.ast as t.File | undefined, + ) + } + } } diff --git a/script/llm/parser/collector/routeCollector.ts b/script/llm/parser/collector/routeCollector.ts index 7ea37307..f2a2dac1 100644 --- a/script/llm/parser/collector/routeCollector.ts +++ b/script/llm/parser/collector/routeCollector.ts @@ -15,7 +15,7 @@ */ import traversePkg, { NodePath } from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules 호환성 이슈로 인한 타입 에러 무시 +// @ts-expect-error - Ignore the type error caused by a CommonJS/ES module compatibility issue. const traverse = traversePkg.default import * as t from "@babel/types" import { RoutePrefix } from "../type/interface" diff --git a/script/llm/parser/utils/extractValue.ts b/script/llm/parser/utils/extractValue.ts index 2f0d6835..3dc083d8 100644 --- a/script/llm/parser/utils/extractValue.ts +++ b/script/llm/parser/utils/extractValue.ts @@ -15,46 +15,249 @@ */ import * as t from "@babel/types" -import traversePkg from "@babel/traverse" -// @ts-expect-error - CommonJS/ES modules 호환성 이슈로 인한 타입 에러 무시 +import traversePkg, { NodePath } from "@babel/traverse" +// @ts-expect-error Interop between CJS/ESM default export of @babel/traverse const traverse = traversePkg.default -import { NodePath } from "@babel/traverse" /** - * Creates identifier information for function calls. - * @param {t.CallExpression} callExpression - Function call expression - * @returns {object} Identifier information + * Convert a call's argument list into a compact string signature. + * + * - Spreads become `...` + * - Argument placeholders become `?` + * - Other expressions are rendered via `exprToString` + * @param {(t.Expression | t.SpreadElement | t.ArgumentPlaceholder)[]} args Call arguments. + * @returns {string} Comma-separated argument signature. */ -export function createFunctionIdentifier(callExpression: t.CallExpression): object { - if (t.isMemberExpression(callExpression.callee)) { - const { object, property } = callExpression.callee - - if (t.isIdentifier(object) && t.isIdentifier(property)) { - return { - type: "function_call", - object: object.name, - method: property.name, - identifier: `${object.name}.${property.name}()`, - } +function argsToString(args: Array): string { + return args + .map((a) => { + if (t.isSpreadElement(a)) return `...${exprToString(a.argument as t.Node)}` + if (t.isArgumentPlaceholder(a)) return "?" + return exprToString(a as t.Node) + }) + .join(", ") +} + +/** + * Retrieve a representative array sample for an identifier that refers to an array. + * + * Looks up: + * 1) `localArrays[name]` for in-scope literals + * 2) `variableMap[name].sample` for previously captured samples + * @param {string | undefined} objName Identifier to resolve. + * @param {Record} localArrays Map of local array literals. + * @param {Record} variableMap Map of prior variable descriptors. + * @returns {any[] | undefined} An example array if known. + */ +function getArraySample( + objName: string | undefined, + localArrays: Record, + variableMap: Record, +): any[] | undefined { + if (!objName) return undefined + if (Array.isArray(localArrays[objName])) return localArrays[objName] + const v = variableMap[objName] + if (v && Array.isArray(v.sample)) return v.sample + return undefined +} + +/** + * Safely converts a value to a string. + * + * - If the value is already a string, it is returned as-is. + * - If the value is an object, it is serialized into a JSON string. + * @param {string | object} v - The value to be converted. + * @returns {string} The string representation of the input value. + */ +function toSig(v: string | object): string { + return typeof v === "string" ? v : JSON.stringify(v) +} +/** + * Create a normalized descriptor for a member access expression. + * + * Supports: + * - Optional chaining (`a?.b`) + * - Computed properties (`a["b"]`, `a?.[x]`) + * @param {t.MemberExpression | t.OptionalMemberExpression} mem Member expression node. + * @returns {{type: 'member_access', object: string, property: string, identifier: string}} Access metadata. + */ + +/** + * + * @param mem + */ +function createMemberAccessIdentifier(mem: t.MemberExpression | t.OptionalMemberExpression) { + const objStr = toSig(exprToString(mem.object)) // <- string | object → string + + let propName = "" + if (t.isIdentifier(mem.property)) propName = mem.property.name + else if (t.isStringLiteral(mem.property)) propName = mem.property.value + else propName = toSig(exprToString(mem.property as t.Node)) // Perform a safe conversion. + + const sep = t.isOptionalMemberExpression(mem) ? "?." : "." + const accessor = mem.computed + ? `${sep}[${toSig(exprToString(mem.property as t.Node))}]` // Perform a safe conversion. + : `${sep}${propName}` + + return { + type: "member_access" as const, + object: objStr, + property: propName, + identifier: `${objStr}${accessor}`, + } +} + +/** + * Render an AST expression as a readable string or structured object. + * + * - Literals → literal string + * - `new Ctor(args)` → `"new Ctor()"` + * - `a.b` / `a?.b` / computed forms → `"a.b"` / `"a?.[b]"` + * - Calls/optional calls → structured via `createInvocationIdentifier` + * @param {t.Node | null | undefined} node AST node to stringify. + * @param {Record} [localArrays] Local arrays for enrichment. + * @param {Record} [variableMap] Variable descriptors for enrichment. + * @returns {string | object} Human-readable signature or a call descriptor. + */ +function exprToString( + node: t.Node | null | undefined, + localArrays: Record = {}, + variableMap: Record = {}, +): string | object { + if (!node) return "" + if (t.isIdentifier(node)) return node.name + if (t.isThisExpression(node)) return "this" + if (t.isSuper(node)) return "super" + if (t.isStringLiteral(node)) return node.value + if (t.isNumericLiteral(node)) return String(node.value) + if (t.isBooleanLiteral(node)) return String(node.value) + if (t.isNullLiteral(node)) return "null" + + if (t.isNewExpression(node)) { + const ctor = exprToString(node.callee, localArrays, variableMap) + const argsSig = node.arguments ? argsToString(node.arguments as any) : "" + return `new ${ctor}(${argsSig})` + } + + if (t.isMemberExpression(node)) { + const obj = exprToString(node.object, localArrays, variableMap) + const prop = node.computed + ? `[${exprToString(node.property as t.Node, localArrays, variableMap)}]` + : `.${exprToString(node.property as t.Node, localArrays, variableMap)}` + return `${obj}${prop}` + } + + if (t.isOptionalMemberExpression(node)) { + const obj = exprToString(node.object, localArrays, variableMap) + const prop = node.computed + ? `?.[${exprToString(node.property as t.Node, localArrays, variableMap)}]` + : `?.${exprToString(node.property as t.Node, localArrays, variableMap)}` + return `${obj}${prop}` + } + + if (t.isOptionalCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sample = getArraySample(info.object, localArrays, variableMap) + if (sample) info.sample = sample } - } else if (t.isIdentifier(callExpression.callee)) { + return info + } + + if (t.isCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sample = getArraySample(info.object, localArrays, variableMap) + if (sample) info.sample = sample + } + return info + } + + return `<${node.type}>` +} + +/** + * Build a normalized descriptor for call and optional-call expressions. + * + * Shapes: + * - Member calls → `{ type: 'function_call', object, method, identifier }` + * - Identifier calls → `{ type: 'function_call', method, identifier }` + * - Complex callees → `{ type: 'function_call', identifier }` + * @param {t.CallExpression | t.OptionalCallExpression} inv Invocation node. + * @param {Record} [localArrays] Local array map for enrichment. + * @param {Record} [variableMap] Variable map for enrichment. + * @returns {object} Call descriptor. + */ +function createInvocationIdentifier( + inv: t.CallExpression | t.OptionalCallExpression, + localArrays: Record = {}, + variableMap: Record = {}, +): object { + const callee = inv.callee as t.Expression + const argsSig = argsToString(inv.arguments) + + if (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) { + const objResult = exprToString(callee.object, localArrays, variableMap) + const objStr = typeof objResult === "string" ? objResult : JSON.stringify(objResult) + + let methodName = "" + if (t.isIdentifier(callee.property)) methodName = callee.property.name + else if (t.isStringLiteral(callee.property)) methodName = callee.property.value + else { + const propResult = exprToString(callee.property as t.Node, localArrays, variableMap) + methodName = typeof propResult === "string" ? propResult : JSON.stringify(propResult) + } + + const sep = t.isOptionalMemberExpression(callee) ? "?." : "." + const accessorForId = callee.computed + ? `${sep}[${exprToString(callee.property as t.Node, localArrays, variableMap)}]` + : `${sep}${methodName}` + + return { + type: "function_call", + object: objStr, + method: methodName, + identifier: `${objStr}${accessorForId}(${argsSig})`, + } + } + + if (t.isIdentifier(callee)) { return { type: "function_call", - method: callExpression.callee.name, - identifier: `${callExpression.callee.name}()`, + method: callee.name, + identifier: `${callee.name}(${argsSig})`, } } + const calleeResult = exprToString(callee, localArrays, variableMap) + const calleeStr = typeof calleeResult === "string" ? calleeResult : JSON.stringify(calleeResult) + return { type: "function_call", - identifier: "", + identifier: `${calleeStr}(${argsSig})`, } } /** - * Handles basic literal values - * @param {t.Node} node Node to extract value from - * @returns {any} Extracted actual value or null + * Create a function-call descriptor while preserving the existing signature, + * extended to support `OptionalCallExpression`. + * @param {t.CallExpression | t.OptionalCallExpression} callExpression Call or optional-call. + * @param {Record} [localArrays] Local arrays for enrichment. + * @param {Record} [variableMap] Variable map for enrichment. + * @returns {object} Call descriptor. + */ +export function createFunctionIdentifier( + callExpression: t.CallExpression | t.OptionalCallExpression, + localArrays: Record = {}, + variableMap: Record = {}, +): object { + return createInvocationIdentifier(callExpression, localArrays, variableMap) +} + +/** + * Extract a primitive value from a literal node. + * @param {t.Node} node Candidate literal node. + * @returns {any} JS value for string/number/boolean/null, or `undefined` if not a basic literal. */ function extractLiteralValue(node: t.Node): any { if (t.isStringLiteral(node)) return node.value @@ -64,13 +267,16 @@ function extractLiteralValue(node: t.Node): any { } /** - * Handles object expressions - * @param {t.ObjectExpression} node Object expression node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Extract a plain object from an `ObjectExpression`. + * + * - Each property value is recursively extracted via `extractValue`. + * - Spread properties are resolved via `resolveSpreadValue`. + * @param {t.ObjectExpression} node Object literal node. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @param {t.File} [ast] Whole-file AST, used to chase identifiers if needed. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Plain JS object representing the expression. */ function extractObjectValue( node: t.ObjectExpression, @@ -91,7 +297,6 @@ function extractObjectValue( ast, visitedVariables, ) - obj[key] = value !== null ? value : null } else if (t.isSpreadElement(prop)) { const resolved = resolveSpreadValue( @@ -133,13 +338,18 @@ function extractObjectValue( } /** - * Handles array expressions - * @param {t.ArrayExpression} node Array expression node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Extract an array from an `ArrayExpression`. + * + * - Elements are recursively extracted via `extractValue`. + * - Spread elements are resolved via `resolveSpreadValue`. + * @param {t.ArrayExpression} node Array literal node. + * @param {Record localArrays Local arrays map. + * @param {Record variableMap Variable descriptors. + * @param localArrays + * @param variableMap + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any[]} Array value. */ function extractArrayValue( node: t.ArrayExpression, @@ -180,13 +390,15 @@ function extractArrayValue( } /** - * Handles identifiers - * @param {t.Identifier} node Identifier node - * @param {Record} localArrays Local array map - * @param {Record} variableMap Variable map - * @param {t.File} ast Complete file AST (for variable tracking) - * @param {Set} visitedVariables Visited variables (to prevent circular references) - * @returns {any} Extracted actual value or null + * Resolve the value of an identifier where possible. + * @param {t.Identifier} node Identifier node. + * @param {Record localArrays Local arrays map. + * @param {Record variableMap Variable descriptors. + * @param localArrays + * @param variableMap + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Resolved value or `null` when unknown. */ function extractIdentifierValue( node: t.Identifier, @@ -201,7 +413,9 @@ function extractIdentifierValue( if (variableMap[name]) { const mapping = variableMap[name] - return mapping.sample || mapping + if (Array.isArray(mapping?.samples)) return mapping.samples + if (Array.isArray(mapping?.sample)) return mapping.sample + return mapping } if (ast) { @@ -212,13 +426,22 @@ function extractIdentifierValue( } /** - * Value extraction function - * @param {t.Node} node - AST node to extract - * @param {Record} localArrays - Map of locally defined array variables - * @param {Record} variableMap - Variable name to data structure mapping - * @param {t.File} ast - Complete file AST (for variable tracking) - * @param {Set} visitedVariables - For preventing circular references - * @returns {any} Extracted actual value or identifier information + * Extract a best-effort JS value (or structured descriptor) from an arbitrary AST node. + * + * Order: + * - Literals → primitive + * - Object → plain object + * - Array → array + * - Identifier → resolved via maps/AST + * - MemberExpression/OptionalMemberExpression → member access descriptor + * - Call/OptionalCall → call descriptor + * - NewExpression → constructor descriptor + * @param {t.Node} node Any AST node to extract. + * @param {Record} localArrays Local arrays map. + * @param {Record} [variableMap] Variable descriptors. + * @param {t.File} [ast] Whole-file AST (optional). + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Extracted value or descriptor, or `null` if not resolvable. */ export function extractValue( node: t.Node, @@ -233,30 +456,83 @@ export function extractValue( if (t.isObjectExpression(node)) { return extractObjectValue(node, localArrays, variableMap, ast, visitedVariables) } - if (t.isArrayExpression(node)) { return extractArrayValue(node, localArrays, variableMap, ast, visitedVariables) } - if (t.isIdentifier(node)) { return extractIdentifierValue(node, localArrays, variableMap, ast, visitedVariables) } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + return createMemberAccessIdentifier(node) + } + + if (t.isOptionalCallExpression(node)) { + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sampleArr = ((): any[] | undefined => { + const v = variableMap[info.object] + if (v && Array.isArray(v?.samples)) return v.samples + if (v && Array.isArray(v?.sample)) return v.sample + const fromLocal = localArrays[info.object] + if (Array.isArray(fromLocal)) return fromLocal + return undefined + })() + if (sampleArr) { + info.samples = sampleArr // Provide the primary output. + if (info.sample === undefined) { + info.sample = sampleArr // Maintain backward compatibility by filling the singular key. + } + } + } + return info + } + if (t.isCallExpression(node)) { - return createFunctionIdentifier(node) + const info: any = createInvocationIdentifier(node, localArrays, variableMap) + if (info && typeof info.object === "string") { + const sampleArr = ((): any[] | undefined => { + const v = variableMap[info.object] + if (v && Array.isArray(v?.samples)) return v.samples + if (v && Array.isArray(v?.sample)) return v.sample + const fromLocal = localArrays[info.object] + if (Array.isArray(fromLocal)) return fromLocal + return undefined + })() + if (sampleArr) { + info.samples = sampleArr + if (info.sample === undefined) { + info.sample = sampleArr + } + } + } + return info + } + if (t.isNewExpression(node)) { + const ctor = exprToString(node.callee, localArrays, variableMap) + const ctorStr = typeof ctor === "string" ? ctor : JSON.stringify(ctor) + const argsSig = node.arguments ? argsToString(node.arguments as any) : "" + return { + type: "constructor_call", + constructor: ctorStr, + identifier: `new ${ctorStr}(${argsSig})`, + } } return null } /** - * Resolves values referenced by spread operators - * @param {t.Node} node - Spread target node - * @param {Record} localArrays - Local array map - * @param {Record} variableMap - Variable map - * @param {t.File} ast - File AST - * @param {Set} visitedVariables - Visited variables - * @returns {any} Resolved value or null + * Resolve the value referenced by a spread argument (`...x`) used in object/array literals. + * + * - Directly returns from `localArrays` or `variableMap` when available. + * - Otherwise searches the AST for variable initializers/assignments. + * @param {t.Node} node Spread argument node. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @param {t.File} [ast] Whole-file AST for identifier resolution. + * @param {Set} [visitedVariables] Cycle guard set. + * @returns {any} Resolved spread value or `null` if not resolvable. */ function resolveSpreadValue( node: t.Node, @@ -338,13 +614,14 @@ function resolveSpreadValue( } /** - * Finds the actual value of a variable from AST - * @param {string} variableName - Variable name to find - * @param {t.File} ast - File AST - * @param {Set} visitedVariables - Visited variables (to prevent circular references) - * @param {Record} localArrays - Local arrays - * @param {Record} variableMap - Variable map - * @returns {any} Actual value of the variable or null + * Find the value assigned to a variable by scanning the AST for matching + * declarations and assignments. Prevents cycles via `visitedVariables`. + * @param {string} variableName Identifier to resolve. + * @param {t.File | undefined} ast Whole-file AST. + * @param {Set} visitedVariables Cycle guard set. + * @param {Record} localArrays Local arrays map. + * @param {Record} variableMap Variable descriptors. + * @returns {any} Resolved value or `null` when not found. */ function findVariableValue( variableName: string, diff --git a/script/llm/prompt/index.ts b/script/llm/prompt/index.ts index 08cd404c..fe6fc913 100644 --- a/script/llm/prompt/index.ts +++ b/script/llm/prompt/index.ts @@ -15,21 +15,33 @@ */ import { itdocExampleJs, itdocExampleTs } from "../examples/index" +import { RouteResult } from "../parser/type/interface" + /** - * Returns a prompt message for generating itdoc functions to create API documentation - * and test cases based on the given test content and language settings. + * Generates an instruction prompt for creating `itdoc` test scripts + * based on analyzed route information and language settings. + * + * The function dynamically selects between JavaScript and TypeScript + * examples and returns a localized prompt (Korean or English) with + * strict formatting and output rules for test code generation. * - * This function reads specified example files (e.g., Express test files) and includes them - * as function examples, then appends additional messages according to the input test content - * and language settings. - * @param {string} content - String containing test content (description of test cases). - * @param {boolean} isEn - If true, sets additional messages to output in English; if false, in Korean. - * @param {number} part - Current part number when output is divided into multiple parts - * @param {boolean} isTypeScript - If true, outputs in TypeScript; if false, in JavaScript - * @returns {string} - Generated prompt message string. + * Rules include: + * - Output only test code lines (no comments, explanations, or prose). + * - Follow order and branching guides when generating tests. + * - Ensure field calls have exactly two arguments. + * - Properly quote HTTP headers with hyphens. + * - Do not generate redundant imports or initialization code. + * - Split long outputs into chunks when necessary. + * @param {RouteResult[]} routesChunk - A chunk of parsed route definitions + * used as the basis for test generation. + * @param {boolean} isEn - Whether to generate the prompt in English (true) or Korean (false). + * @param {number} part - The sequential part number of the response when the GPT call does not return the full output at once and multiple calls are made to retrieve the continuation. + * @param {boolean} [isTypeScript] - Whether to generate TypeScript-based test examples instead of JavaScript. + * @returns {string} A formatted prompt containing route information, language rules, + * and example code for generating `itdoc` tests. */ export function getItdocPrompt( - content: string, + routesChunk: RouteResult[], isEn: boolean, part: number, isTypeScript: boolean = false, @@ -41,84 +53,91 @@ export function getItdocPrompt( codeTypes: { js: "자바스크립트", ts: "타입스크립트" }, outputInstruction: "그리고 반드시 한글로 출력해야 합니다.", codeLabel: "코드", + noComments: + "설명, 주석(//, /* */), 백틱 코드블록(```), 여는/닫는 문구를 절대 포함하지 마세요.", + orderGuide: + "출력 순서는 경로(prefix)와 메서드 순으로 정렬하세요. 기본 응답 테스트 → 각 분기 테스트 순으로 묶어서 작성하세요.", + branchGuide: + "각 라우트에 정의된 기본 응답과 모든 branches(조건)를 각각 별도의 itdoc 테스트로 생성하세요.", + branchGuide2: + "만약 default.status의 값이 빈 배열이라면 해당 default 조건에 해당하는 테스트는 절대 작성하지 않습니다.", + fieldGuide: + '모든 field 호출은 반드시 field("설명", "예시값")처럼 2개의 매개변수를 포함해야 합니다(단일 인자 금지).', + fieldGuide2: + '만약 a : b 라고 했을 때 b가 객체인 경우 field는 field는("b설명", b) 이런식으로 객체 전체를 설명해야 합니다. 객체 안에 field가 들어가면 안됩니다.', + headerGuide: + 'HTTP 헤더 키에 하이픈(-)이 포함되어 있으면 반드시 큰따옴표로 감싸세요. 예: "Cache-Control", "Last-Modified"', + noPathImport: + "파일 경로 import/require(예: examples/express-*)는 출력하지 마세요. 테스트 러너/초기 설정 코드는 이미 주어졌습니다.", + initGiven: + '다음 초기 설정은 이미 포함되어 있으므로 생성하지 마세요: const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc")', + chunksGuideFirst: + "출력이 길어질 경우 다음 요청에서 이어받을 수 있도록 적절한 단위로 분할하여 출력하세요. 응답 마지막에 '...' 같은 기호는 넣지 마세요.", + chunksGuideNext: + "이전 출력의 이어지는 부분만 출력하세요. 이전 내용을 반복하지 마세요. 분할 제목(1/3 등)은 금지합니다.", + langOut: "출력은 반드시 한글로 작성하세요.", + codeOnly: "오직 테스트 코드 줄만 출력하세요.", + etc: "function_call로 되어있는 부분은 그대로 함수로 출력해야 합니다. ex) fetchedAt: field('조회 시각(ISO 8601 형식)', new Date().toISOString()) 또한, parseInt() 등으로 타입을 유추할 수 있는 경우 해당 타입으로 반환해야 합니다. ex)parseInt(page) 의 경우 1, 2 등의 int 타입으로 출력되어야 합니다. 그리고 describeAPI에는 반드시 summary, tag, description값이 들어가야 합니다. 존재하지 않는 엔드포인트나 파라미터/헤더/바디 필드는 만들지 마세요. 입력에 있는 정보만 사용하세요. res() 이후에 반드시 유효한 status()가 붙어야 합니다. responses에서 주어지는 json 값들은 임의로 바꾸지 않습니다.", }, en: { codeTypes: { js: "JavaScript", ts: "TypeScript" }, outputInstruction: "And the output must be in English.", codeLabel: "code", + noComments: + "Do NOT include explanations, comments (// or /* */), or fenced code blocks (```), or any opening/closing prose.", + orderGuide: + "Order tests by path prefix and HTTP method. For each route, output the default response test first, then branch tests.", + branchGuide: + "For every route, generate separate itdoc tests for the default response and for every branch condition.", + branchGuide2: + "If the default.status value is an empty array, never create a test that corresponds to that default condition.", + fieldGuide: + 'Every field call MUST have exactly two arguments: field("label", "example"). Single-argument calls are forbidden.', + fieldGuide2: + 'If b is an object when a : b, the field should describe the whole object like this ("b description", b). The field should not be inside the object.', + headerGuide: + 'If an HTTP header key contains a hyphen (-), it MUST be quoted, e.g., "Cache-Control", "Last-Modified".', + noPathImport: + "Do not output any file-path import/require lines (e.g., examples/express-*). The runner/bootstrap is already provided.", + initGiven: + 'Do not generate the following since it is already included: const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc")', + chunksGuideFirst: + "If output is long, split into reasonable parts that can be continued later. Do not append trailing ellipses like '...'.", + chunksGuideNext: + "Output only the continuation of the previous part. Do NOT repeat earlier content. Do NOT print part titles like (1/3).", + langOut: "The output must be in English.", + codeOnly: "Output only test code lines.", + etc: "The part with function_call must be output as a function as it is. ex) fetchedAt: field('Inquiry Time (ISO 8601)', new Date().toISOString()) Also, if the type can be inferred by pathInt(), etc., it must be returned to that type. ex) For pathInt(page), it must be output in int type such as 1, 2, etc. And describeAPI must have summary, tag, and description values. Do not create non-existent endpoints or parameter/header/body fields. Use only the information in the input. Be sure to have a valid status () after res().The json values given in responses are not arbitrarily changed.", }, } as const const lang = isEn ? LANGUAGE_TEXTS.en : LANGUAGE_TEXTS.ko const codeType = isTypeScript ? lang.codeTypes.ts : lang.codeTypes.js const codeMessage = `${codeType} ${lang.codeLabel}` - - const partGuide = - part > 1 - ? `이전 출력의 이어지는 ${part}번째 부분만 출력하세요. 이전 내용을 반복하지 마세요.` - : `출력이 길어질 경우 다음 요청에서 이어받을 수 있도록 적절한 단위로 분할하여 출력하세요. 응답 마지막에 '...' 같은 기호는 넣지 마세요.` + const partGuide = part > 1 ? lang.chunksGuideNext : lang.chunksGuideFirst return ` -다음 테스트 내용을 기반으로 itdoc 테스트 스크립트를 ${codeMessage}로 생성해주세요. -- 모든 라우터에 대한 테스트를 포함해야 합니다. -- field는 field("a", "b") 처럼 2개의 매개변수를 반드시 포함해야 합니다. field로만 나오면 안됩니다. -- 중복되는 설명은 없어야 합니다. -- HTTP 헤더와 같이 하이픈(-)이 포함된 키는 반드시 큰따옴표로 감싸야 합니다. - 올바른 예시: "Cache-Control", "Last-Modified" - 잘못된 예시: Cache-Control, Last-Modified (no) -- 코드 설명 없이 코드만 출력해야 하며, \`(1/10)\` 같은 자동 분할 제목은 넣지 마세요. -- 출력은 ${codeMessage}로만 구성되어야 하며, 백틱 블록(\`\`\`)도 사용하지 않습니다. +다음 테스트 설명을 기반으로 itdoc 테스트 스크립트를 ${codeMessage}로 생성하세요. + +필수 규칙: +- ${lang.noComments} +- ${lang.codeOnly} +- ${lang.orderGuide} +- ${lang.branchGuide} +- ${lang.branchGuide2} +- ${lang.fieldGuide} +- ${lang.fieldGuide2} +- ${lang.headerGuide} +- ${lang.noPathImport} +- ${lang.initGiven} +- ${lang.etc} - ${partGuide} -${lang.outputInstruction} +- ${lang.outputInstruction} -[테스트 설명 내용] -${content} +[테스트를 진행해야하는 라우트 분석 결과] +${JSON.stringify(routesChunk, null, 2)} [함수 예시] ${itdocExample} -- 경로에 해당하는 코드는 출력하지 말아야 합니다. -ex) import { app } from "examples/express-ts/index.ts" - 또는 - const app = require("examples/express/index.js") - -- 아래 초기 설정 코드는 이미 포함되어 있으니 해당부분은 생성하지 말아야 합니다. -const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") -- header() 메서드는 다음과 같이 객체가 포함되어야 합니다. -header({ - Authorization: field("인증 토큰", "Bearer 123456"), -}) `.trim() } - -/** - * Returns a prompt for creating JSON-based API specifications in Markdown format. - * @param {any} content - JSON object containing API definitions. - * @param {number} part - Current part number when output is divided into multiple parts - * @returns {string} - Prompt message for Markdown generation. - */ -export function getMDPrompt(content: any, part?: number): string { - const partNote = part ? ` (이 문서는 ${part}번째 요청입니다)` : "" - return `다음 JSON을 바탕으로 API 테스트 케이스만 Markdown 형식으로 작성하세요${partNote}. -입력 JSON: -${JSON.stringify(content, null, 2)} -출력 포맷 (각 항목만): -- 엔드포인트 (예: GET /api/products) -- 테스트 이름 (간결하게) -- 요청 조건 (필요 시 요청 바디·경로 매개변수 등 자세하게 표현할 것) -- 예상 응답 (상태 코드 및 반환되는 객체 또는 메시지 등 자세하게 표현할 것) - -예시) -PUT /api/products/:id -- 테스트 이름: 제품 업데이트 성공 -- 요청 조건: 유효한 제품 ID와 name, price, category 제공 -- 예상 응답: 상태 코드 500, "message"가 "Server is running"인 JSON 응답, "data"에 "timestamp", "uptime", "memoryUsage" 정보 포함 - -지켜야 할 사항: -1. 제목·개요·요약·결론 등의 부가 설명은 절대 포함하지 마세요. -2. 일반 지침 문구(“정상 흐름과 오류 흐름을 모두 포함” 등)도 쓰지 않습니다. -3. 마크다운 강조(**), 코드 블록 안의 별도 스타일링은 사용 금지입니다. -4. 출력이 길어지면 (1 / 3), (2 / 3)처럼 순서 표기하여 분할하세요. -5. 오직 테스트 케이스 목록만, 항목별로 구분해 나열합니다. -6. DELETE, PUT, GET, POST, PATCH 앞에 - 를 붙이지 않습니다. -` -} diff --git a/script/makedocs/index.ts b/script/makedocs/index.ts index 365f0cb1..b1934c93 100644 --- a/script/makedocs/index.ts +++ b/script/makedocs/index.ts @@ -40,11 +40,9 @@ export async function generateDocs(oasOutputPath: string, outputDir: string): Pr logger.info(`HTML path: ${htmlPath}`) const config = await loadConfig({}) - logger.info("Step 1: Redocly configuration loaded") const bundleResult = await bundle({ ref: oasOutputPath, config }) const api = bundleResult.bundle.parsed - logger.info("Step 2: OpenAPI bundling completed") const widdershinsOpts = { headings: 2, summary: true } console.log = () => {} const markdown = await widdershins.convert(api, widdershinsOpts)