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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions examples/express/expected/oas.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
}
}
},
"/users/:userId": {
"/users/{userId}": {
"get": {
"summary": "사용자 조회 API",
"tags": ["User"],
Expand Down Expand Up @@ -383,7 +383,7 @@
}
}
},
"/users/:userId/friends/:friendName": {
"/users/{userId}/friends/{friendName}": {
"delete": {
"summary": "특정 사용자의 친구를 삭제합니다.",
"tags": ["User"],
Expand Down
38 changes: 38 additions & 0 deletions lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

describe("OpenAPIGenerator", () => {
let generator: OpenAPIGenerator
let originalGetInstance: any

Check warning on line 24 in lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts

View workflow job for this annotation

GitHub Actions / Test, Build, and Lint (20.x)

Unexpected any. Specify a different type

before(() => {
originalGetInstance = OpenAPIGenerator.getInstance
Expand Down Expand Up @@ -55,7 +55,7 @@
}

generator.collectTestResult(testResult)
const spec = generator.generateOpenAPISpec() as any

Check warning on line 58 in lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts

View workflow job for this annotation

GitHub Actions / Test, Build, and Lint (20.x)

Unexpected any. Specify a different type

assert.isDefined(spec.paths["/test/empty"].get.responses["200"])
assert.isUndefined(spec.paths["/test/empty"].get.responses["200"].content)
Expand All @@ -76,7 +76,7 @@
}

generator.collectTestResult(testResult)
const spec = generator.generateOpenAPISpec() as any

Check warning on line 79 in lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts

View workflow job for this annotation

GitHub Actions / Test, Build, and Lint (20.x)

Unexpected any. Specify a different type

assert.isDefined(spec.paths["/test/null"].get.responses["200"])
assert.isUndefined(spec.paths["/test/null"].get.responses["200"].content)
Expand All @@ -99,7 +99,7 @@
}

generator.collectTestResult(testResult)
const spec = generator.generateOpenAPISpec() as any

Check warning on line 102 in lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts

View workflow job for this annotation

GitHub Actions / Test, Build, and Lint (20.x)

Unexpected any. Specify a different type

assert.isDefined(spec.paths["/test/undefined-body"].get.responses["400"])
assert.isUndefined(spec.paths["/test/undefined-body"].get.responses["400"].content)
Expand All @@ -123,7 +123,7 @@
}

generator.collectTestResult(testResult)
const spec = generator.generateOpenAPISpec() as any

Check warning on line 126 in lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts

View workflow job for this annotation

GitHub Actions / Test, Build, and Lint (20.x)

Unexpected any. Specify a different type

assert.isDefined(spec.paths["/test/error"].get.responses["404"])
assert.isDefined(spec.paths["/test/error"].get.responses["404"].content)
Expand Down Expand Up @@ -170,7 +170,7 @@
}

generator.collectTestResult(testResult)
const spec = generator.generateOpenAPISpec() as any

Check warning on line 173 in lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts

View workflow job for this annotation

GitHub Actions / Test, Build, and Lint (20.x)

Unexpected any. Specify a different type

assert.isDefined(spec.paths["/test/success"].get.responses["200"])
assert.isDefined(spec.paths["/test/success"].get.responses["200"].content)
Expand Down Expand Up @@ -225,10 +225,48 @@

generator.collectTestResult(testResult1)
generator.collectTestResult(testResult2)
const spec = generator.generateOpenAPISpec() as any

Check warning on line 228 in lib/__tests__/unit/dsl/OpenAPIGenerator.test.ts

View workflow job for this annotation

GitHub Actions / Test, Build, and Lint (20.x)

Unexpected any. Specify a different type

assert.isDefined(spec.paths["/test/no-body-responses"].get.responses["400"])
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}")
})
})
})
36 changes: 32 additions & 4 deletions lib/dsl/generator/OpenAPIGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,15 @@ export class OpenAPIGenerator implements IOpenAPIGenerator {
const paths: Record<string, Record<string, unknown>> = {}

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,
)
}
}

Expand Down Expand Up @@ -533,8 +538,7 @@ export class OpenAPIGenerator implements IOpenAPIGenerator {
*/
private validatePathParameters(paths: Record<string, Record<string, unknown>>): 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

Expand Down Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion lib/dsl/test-builders/RequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -31,9 +32,18 @@ export class RequestBuilder extends AbstractTestBuilder {
*/
public header(headers: Record<string, DSLField<string>>): this {
const normalizedHeaders: Record<string, DSLField<string>> = {}
const seen = new Set<string>()

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
Expand Down
Loading