diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index c98ba29..22475a0 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -89,7 +89,7 @@ } } }, - "/users/:userId": { + "/users/{userId}": { "get": { "summary": "사용자 조회 API", "tags": ["User"], @@ -383,7 +383,7 @@ } } }, - "/users/:userId/friends/:friendName": { + "/users/{userId}/friends/{friendName}": { "delete": { "summary": "특정 사용자의 친구를 삭제합니다.", "tags": ["User"], diff --git a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts index 26860f4..53d5c9f 100644 --- a/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts +++ b/lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts @@ -231,4 +231,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/dsl/generator/OpenAPIGenerator.ts b/lib/dsl/generator/OpenAPIGenerator.ts index 1a2a38b..88a0afa 100644 --- a/lib/dsl/generator/OpenAPIGenerator.ts +++ b/lib/dsl/generator/OpenAPIGenerator.ts @@ -130,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, + ) } } @@ -533,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 @@ -571,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/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 2a89b40..8b4ae50 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -19,6 +19,7 @@ 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. @@ -31,9 +32,18 @@ export class RequestBuilder extends AbstractTestBuilder { */ public header(headers: Record>): this { const normalizedHeaders: Record> = {} + const seen = new Set() Object.entries(headers).forEach(([headerName, headerValue]) => { - normalizedHeaders[headerName.toLowerCase()] = 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