From 2cd3c4afba16159db08d13b1e5d941c21cb0ef95 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 28 Sep 2025 01:01:33 +0900 Subject: [PATCH 01/22] create dsl req().file() for upload single file api --- lib/dsl/interface/field.ts | 5 ++ lib/dsl/test-builders/RequestBuilder.ts | 66 ++++++++++++++++++++++++- lib/dsl/test-builders/TestCaseConfig.ts | 3 +- 3 files changed, 72 insertions(+), 2 deletions(-) diff --git a/lib/dsl/interface/field.ts b/lib/dsl/interface/field.ts index c3027e2..e11f33e 100644 --- a/lib/dsl/interface/field.ts +++ b/lib/dsl/interface/field.ts @@ -33,6 +33,11 @@ export interface DSLField { readonly required: boolean } +export interface DSLRequestFile { + file: { path?: string; buffer?: Buffer; stream?: NodeJS.ReadableStream } + opts: { contentType: string; filename?: string } +} + /** * DSL Helper Functions * - DSL Field creation function diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 8b4ae50..ae41cf0 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -17,7 +17,7 @@ // import { PATH_PARAM_TYPES, QUERY_PARAM_TYPES } from "./TestCaseConfig" import { DSLField } from "../interface" import { ResponseBuilder } from "./ResponseBuilder" -import { FIELD_TYPES } from "../interface/field" +import { DSLRequestFile, FIELD_TYPES } from "../interface/field" import { AbstractTestBuilder } from "./AbstractTestBuilder" import logger from "../../config/logger" @@ -47,6 +47,57 @@ export class RequestBuilder extends AbstractTestBuilder { }) this.config.requestHeaders = normalizedHeaders + if (headers["content-type"]) { + throw new Error('You cannot set "Content-Type" header using header().') + } + this.config.requestHeaders = headers + return this + } + + /** + * Sets the request body as a raw file (NOT multipart/form-data). + * + * - Accepts a {@link DSLRequestFile} containing: + * • `file`: source (exactly one of path | buffer | stream) + * • `opts`: metadata such as contentType (required) and filename (optional) + * - Mutually exclusive with {@link body()}. + * @param requestFile + * @example + * req().file({ + * file: { path: "./fixtures/report.pdf" }, + * opts: { contentType: "application/pdf", filename: "report.pdf" } + * }) + */ + public file(requestFile: DSLRequestFile): this { + const { file } = requestFile + + const sources = [file.path ? 1 : 0, file.buffer ? 1 : 0, file.stream ? 1 : 0].reduce( + (a, b) => a + b, + 0, + ) + if (sources === 0) { + throw new Error("req().file(): provide one of file.path | file.buffer | file.stream.") + } + if (sources > 1) { + throw new Error( + "req().file(): only one of file.path | file.buffer | file.stream must be provided.", + ) + } + + if (this.config.requestBody) { + throw new Error( + [ + "❌ Conflict: request body has already been set using .body().", + "", + "You cannot mix JSON body (.body()) and raw file (.file()) in the same request.", + "Please choose exactly one of:", + " • req().body(...) → for JSON payloads", + " • req().file(...) → for raw binary uploads (application/octet-stream)", + ].join("\n"), + ) + } + + this.config.requestFile = requestFile return this } @@ -56,6 +107,19 @@ export class RequestBuilder extends AbstractTestBuilder { * @returns {this} Request builder instance */ public body(body: Record | FIELD_TYPES>): this { + if (this.config.requestBody) { + throw new Error( + [ + "❌ Conflict: request body has already been set using .body().", + "", + "You cannot mix JSON body (.body()) and raw file (.file()) in the same request.", + "Please choose exactly one of:", + " • req().body(...) → for JSON payloads", + " • req().file(...) → for raw binary uploads (application/octet-stream)", + ].join("\n"), + ) + } + this.config.requestBody = body return this } diff --git a/lib/dsl/test-builders/TestCaseConfig.ts b/lib/dsl/test-builders/TestCaseConfig.ts index 97a6ec4..3a889bd 100644 --- a/lib/dsl/test-builders/TestCaseConfig.ts +++ b/lib/dsl/test-builders/TestCaseConfig.ts @@ -16,7 +16,7 @@ import { HttpStatus } from "../enums" import { DSLField } from "../interface" -import { FIELD_TYPES } from "../interface/field" +import { DSLRequestFile, FIELD_TYPES } from "../interface/field" import { ApiDocOptions } from "../interface" export type PATH_PARAM_TYPES = string | number @@ -34,6 +34,7 @@ export interface TestCaseConfig { queryParams?: Record | QUERY_PARAM_TYPES> requestBody?: Record requestHeaders?: Record | string> + requestFile?: DSLRequestFile expectedStatus?: HttpStatus | number expectedResponseBody?: Record expectedResponseHeaders?: Record | string> From 49804802a5e3ad29ced3afe43874877a8027f360 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Mon, 29 Sep 2025 21:49:21 +0900 Subject: [PATCH 02/22] create fileField dsl sample. --- lib/dsl/index.ts | 2 +- lib/dsl/interface/field.ts | 18 ++++++++++++++++-- lib/dsl/interface/index.ts | 6 +++--- lib/dsl/test-builders/RequestBuilder.ts | 3 +++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/dsl/index.ts b/lib/dsl/index.ts index 5692bb7..e909f8f 100644 --- a/lib/dsl/index.ts +++ b/lib/dsl/index.ts @@ -16,5 +16,5 @@ export { HttpMethod } from "./enums/HttpMethod" export { HttpStatus } from "./enums/HttpStatus" -export { describeAPI, itDoc, field } from "./interface" +export { describeAPI, itDoc, field, fileField } from "./interface" export type { ApiDocOptions } from "./interface/ItdocBuilderEntry" diff --git a/lib/dsl/interface/field.ts b/lib/dsl/interface/field.ts index e11f33e..793fd30 100644 --- a/lib/dsl/interface/field.ts +++ b/lib/dsl/interface/field.ts @@ -34,8 +34,9 @@ export interface DSLField { } export interface DSLRequestFile { - file: { path?: string; buffer?: Buffer; stream?: NodeJS.ReadableStream } - opts: { contentType: string; filename?: string } + readonly description: string + readonly file: { path?: string; buffer?: Buffer; stream?: NodeJS.ReadableStream } + readonly opts: { contentType: string; filename?: string } } /** @@ -54,6 +55,19 @@ export function field( return { description, example, required } as DSLField } +/** + * DSL File Field creation function + * @param {string} description Field description to be displayed in documentation + * @param {string} filePath Local file path for the upload + */ +export function fileField(description: string, filePath: string): DSLRequestFile { + return { + description, + file: { path: filePath }, + opts: { contentType: "application/octet-stream", filename: filePath }, + } satisfies DSLRequestFile +} + /** * DSL Field type guard * @description diff --git a/lib/dsl/interface/index.ts b/lib/dsl/interface/index.ts index c469916..b38767e 100644 --- a/lib/dsl/interface/index.ts +++ b/lib/dsl/interface/index.ts @@ -16,8 +16,8 @@ import { describeAPI } from "./describeAPI" import { itDoc } from "./itDoc" -import { field, DSLField } from "./field" +import { field, fileField, DSLField, DSLRequestFile } from "./field" import { ApiDocOptions } from "./ItdocBuilderEntry" -export { describeAPI, itDoc, field } -export type { ApiDocOptions, DSLField } +export { describeAPI, itDoc, field, fileField } +export type { ApiDocOptions, DSLField, DSLRequestFile } diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index ae41cf0..dc327c8 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -69,6 +69,9 @@ export class RequestBuilder extends AbstractTestBuilder { * }) */ public file(requestFile: DSLRequestFile): this { + if (!requestFile || typeof requestFile !== "object") { + throw new Error("req().file(): you must provide a requestFile object as an argument.") + } const { file } = requestFile const sources = [file.path ? 1 : 0, file.buffer ? 1 : 0, file.stream ? 1 : 0].reduce( From 6086c75c5857b6556e289b457a023c508e0980db Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Mon, 29 Sep 2025 22:00:53 +0900 Subject: [PATCH 03/22] create examples/express for testing file upload api --- examples/express/expressApp.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/examples/express/expressApp.js b/examples/express/expressApp.js index 83a9f65..0125e62 100644 --- a/examples/express/expressApp.js +++ b/examples/express/expressApp.js @@ -251,4 +251,20 @@ app.get("/failed-test", (req, res) => { }) }) +app.post("/uploads", (req, res) => { + const file = req.body + if (!file || file.length === 0) { + return res.status(400).json({ + error: "No file uploaded", + }) + } + + return res.status(201).json({ + fileId: "file123", + fileName: "uploaded_file.txt", + fileSize: file.length, + uploadTime: new Date().toISOString(), + }) +}) + module.exports = app From c6a537cda191de2fa8ca89a1fb9e2f3aa8acb353 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Mon, 29 Sep 2025 22:01:06 +0900 Subject: [PATCH 04/22] sample tests --- examples/express/__tests__/expressApp.test.js | 45 ++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/examples/express/__tests__/expressApp.test.js b/examples/express/__tests__/expressApp.test.js index d88b119..795ef2c 100644 --- a/examples/express/__tests__/expressApp.test.js +++ b/examples/express/__tests__/expressApp.test.js @@ -1,5 +1,5 @@ const app = require("../expressApp.js") -const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") +const { describeAPI, itDoc, HttpStatus, field, fileField, HttpMethod } = require("itdoc") const targetApp = app describeAPI( @@ -493,6 +493,7 @@ describeAPI( }) }, ) + describeAPI( HttpMethod.GET, "/failed-test", @@ -515,3 +516,45 @@ describeAPI( }) }, ) + +describeAPI( + HttpMethod.POST, + "/uploads", + { + summary: "파일 업로드 API", + tag: "File", + description: "파일을 업로드합니다.", + }, + targetApp, + (apiDoc) => { + before(() => { + // tmp 폴더에 example.txt 파일 생성 + const fs = require("fs") + const path = require("path") + const dir = path.join(__dirname, "../tmp") + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir) + } + fs.writeFileSync(path.join(dir, "example.txt"), "This is an example file.") + }) + + // with filePath + itDoc("파일 업로드 성공", async () => { + await apiDoc + .test() + .req() + .file( + fileField("업로드할 파일", { + path: require("path").join(__dirname, "../tmp/example.txt"), + }), + ) + .res() + .status(HttpStatus.CREATED) + .body({ + success: true, + message: field("성공 메시지", "File uploaded successfully"), + fileId: field("파일 ID", "file123"), + }) + }) + }, +) From 2d54323c3ee4e1aa3d2e25bf8bd5cb952a95efae Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Mon, 29 Sep 2025 22:01:26 +0900 Subject: [PATCH 05/22] add some validate fileField --- lib/dsl/interface/field.ts | 40 ++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/lib/dsl/interface/field.ts b/lib/dsl/interface/field.ts index 793fd30..9863222 100644 --- a/lib/dsl/interface/field.ts +++ b/lib/dsl/interface/field.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import fs from "fs" + export type FIELD_TYPES = | string | number @@ -58,13 +60,43 @@ export function field( /** * DSL File Field creation function * @param {string} description Field description to be displayed in documentation - * @param {string} filePath Local file path for the upload + * @param {string} path Local file path for the upload + * @param buffer Buffer containing file data + * @param stream Readable stream containing file data + * @param filename (Optional) Filename to be used in the upload (if not provided, the name from filePath will be used) */ -export function fileField(description: string, filePath: string): DSLRequestFile { +export function fileField( + description: string, + { + path, + buffer, + stream, + filename, + }: { path?: string; buffer?: Buffer; stream?: NodeJS.ReadableStream; filename?: string }, +): DSLRequestFile { + if (path) { + if (!fs.existsSync(path)) { + throw new Error(`fileField(): path "${path}" does not exist.`) + } + } else if (buffer) { + if (!Buffer.isBuffer(buffer)) { + throw new Error("fileField(): buffer must be a Buffer instance.") + } + } else if (stream) { + if ( + typeof stream !== "object" || + typeof (stream as NodeJS.ReadableStream).pipe !== "function" + ) { + throw new Error("fileField(): stream must be a Readable stream.") + } + } else { + throw new Error("fileField(): provide one of path | buffer | stream.") + } + return { description, - file: { path: filePath }, - opts: { contentType: "application/octet-stream", filename: filePath }, + file: { path, buffer, stream }, + opts: { contentType: "application/octet-stream", filename }, } satisfies DSLRequestFile } From 34bfb9839ac709ba941f0bee573394738457b968 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sat, 4 Oct 2025 17:53:44 +0900 Subject: [PATCH 06/22] update --- examples/express/expressApp.js | 27 +++++--- lib/dsl/generator/OpenAPIGenerator.ts | 3 +- .../builders/operation/RequestBodyBuilder.ts | 50 +++++++++----- lib/dsl/generator/types/TestResult.ts | 1 + lib/dsl/test-builders/RequestBuilder.ts | 16 +++-- lib/dsl/test-builders/ResponseBuilder.ts | 67 ++++++++++++++++++- package.json | 2 +- 7 files changed, 132 insertions(+), 34 deletions(-) diff --git a/examples/express/expressApp.js b/examples/express/expressApp.js index 0125e62..62fa3b3 100644 --- a/examples/express/expressApp.js +++ b/examples/express/expressApp.js @@ -252,18 +252,25 @@ app.get("/failed-test", (req, res) => { }) app.post("/uploads", (req, res) => { - const file = req.body - if (!file || file.length === 0) { - return res.status(400).json({ - error: "No file uploaded", - }) + if (req.headers["content-type"] !== "application/octet-stream") { + res.status(400).json({ error: "Invalid content type" }) } - return res.status(201).json({ - fileId: "file123", - fileName: "uploaded_file.txt", - fileSize: file.length, - uploadTime: new Date().toISOString(), + let uploadedBytes = 0 + + req.on("data", (chunk) => { + uploadedBytes += chunk.length + }) + + req.on("end", () => { + if (uploadedBytes === 0) { + res.status(400).json({ error: "No file uploaded" }) + } + res.status(201).json() + }) + + req.on("error", () => { + res.status(500).json({ error: "Upload failed" }) }) }) diff --git a/lib/dsl/generator/OpenAPIGenerator.ts b/lib/dsl/generator/OpenAPIGenerator.ts index 88a0afa..bef86cc 100644 --- a/lib/dsl/generator/OpenAPIGenerator.ts +++ b/lib/dsl/generator/OpenAPIGenerator.ts @@ -523,7 +523,8 @@ export class OpenAPIGenerator implements IOpenAPIGenerator { operation.parameters = requestObj.parameters } - if (result.request?.body && requestObj.requestBody) { + const reqBody = requestObj.requestBody + if (reqBody) { operation.requestBody = requestObj.requestBody } diff --git a/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts b/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts index 92d97e3..ac9e693 100644 --- a/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts +++ b/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts @@ -33,28 +33,43 @@ export class RequestBodyBuilder implements RequestBodyBuilderInterface { * @returns Request body object or undefined */ public generateRequestBody(result: TestResult): RequestBodyObject | undefined { - if (!result.request.body) { - return undefined - } - const contentType = this.getContentType(result.request) - const schema = SchemaBuilder.inferSchema(result.request.body) as Record - const content: Content = { - [contentType]: { - schema, - }, + + if (result.request.file) { + const content: Content = { + [contentType]: { + schema: { + type: "string", + format: "binary", + }, + }, + } + + return { + content, + required: true, + } } if (result.request.body) { + const schema = SchemaBuilder.inferSchema(result.request.body) as Record + const content: Content = { + [contentType]: { + schema, + }, + } + content[contentType].example = this.utilityBuilder.extractSimpleExampleValue( result.request.body, ) - } - return { - content, - required: true, + return { + content, + required: true, + } } + + return undefined } /** @@ -63,8 +78,13 @@ export class RequestBodyBuilder implements RequestBodyBuilderInterface { * @returns Content-Type value */ private getContentType(request: TestResult["request"]): string { - if (request.headers && "content-type" in request.headers) { - const contentType = request.headers["content-type"] + if (request.headers) { + // case ignores + const headers = Object.fromEntries( + Object.entries(request.headers).map(([k, v]) => [k.toLowerCase(), v]), + ) + const contentType = headers["content-type"] + if (typeof contentType === "string") { return contentType } else if (isDSLField(contentType) && typeof contentType.example === "string") { diff --git a/lib/dsl/generator/types/TestResult.ts b/lib/dsl/generator/types/TestResult.ts index 1bdf0c3..f8014f0 100644 --- a/lib/dsl/generator/types/TestResult.ts +++ b/lib/dsl/generator/types/TestResult.ts @@ -41,6 +41,7 @@ export interface TestResult { url: string options: ApiDocOptions request: { + file?: unknown body?: unknown headers?: Record queryParams?: Record diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index dc327c8..8b522bd 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -69,18 +69,26 @@ export class RequestBuilder extends AbstractTestBuilder { * }) */ public file(requestFile: DSLRequestFile): this { + if (this.config.requestHeaders) { + throw new Error("already defined headers. can't use file()") + } + + this.config.requestHeaders = { + ...(this.config.requestHeaders ?? {}), + "Content-Type": "application/octet-stream", + } + if (!requestFile || typeof requestFile !== "object") { - throw new Error("req().file(): you must provide a requestFile object as an argument.") + logger.warn("req().file(): provide one of file.path | file.buffer | file.stream.") + return this } + const { file } = requestFile const sources = [file.path ? 1 : 0, file.buffer ? 1 : 0, file.stream ? 1 : 0].reduce( (a, b) => a + b, 0, ) - if (sources === 0) { - throw new Error("req().file(): provide one of file.path | file.buffer | file.stream.") - } if (sources > 1) { throw new Error( "req().file(): only one of file.path | file.buffer | file.stream must be provided.", diff --git a/lib/dsl/test-builders/ResponseBuilder.ts b/lib/dsl/test-builders/ResponseBuilder.ts index 75f3a17..2fcb355 100644 --- a/lib/dsl/test-builders/ResponseBuilder.ts +++ b/lib/dsl/test-builders/ResponseBuilder.ts @@ -15,7 +15,7 @@ */ import { HttpStatus } from "../enums" -import { DSLField } from "../interface" +import { DSLField, DSLRequestFile } from "../interface" import supertest, { Response } from "supertest" import { validateResponse } from "./validateResponse" import { isDSLField } from "../interface/field" @@ -23,6 +23,7 @@ import { AbstractTestBuilder } from "./AbstractTestBuilder" import { recordTestFailure, resultCollector, TestResult } from "../generator" import logger from "../../config/logger" import { testContext } from "../interface/testContext" +import fs from "fs" /** * Builder class for setting result values to validate API responses. @@ -93,7 +94,54 @@ export class ResponseBuilder extends AbstractTestBuilder { for (const [key, fieldObj] of Object.entries(this.config.requestBody || {})) { body[key] = isDSLField(fieldObj) ? fieldObj.example : fieldObj } - req = req.send(body) + if (Object.keys(body).length > 0) { + req = req.send(body) + } + + if (this.config.requestFile) { + const requestFile: DSLRequestFile = this.config.requestFile + if ( + !requestFile.file || + (!requestFile.file.path && !requestFile.file.buffer && !requestFile.file.stream) + ) { + logger.warn("req().file(): provide one of file.path | file.buffer | file.stream.") + } else if (requestFile.file.path) { + const hasContentType = + !!this.config.requestHeaders && + Object.keys(this.config.requestHeaders).some( + (k) => k.toLowerCase() === "content-type", + ) + if (!hasContentType) { + req.set("Content-Type", "application/octet-stream") + } + const buf = fs.readFileSync(requestFile.file.path) + req = req.send(buf) + } else if (requestFile.file.buffer) { + const hasContentType = + !!this.config.requestHeaders && + Object.keys(this.config.requestHeaders).some( + (k) => k.toLowerCase() === "content-type", + ) + if (!hasContentType) { + req.set("Content-Type", "application/octet-stream") + } + } else if (requestFile.file.stream) { + const hasContentType = + !!this.config.requestHeaders && + Object.keys(this.config.requestHeaders).some( + (k) => k.toLowerCase() === "content-type", + ) + if (!hasContentType) { + req.set("Content-Type", "application/octet-stream") + } + const chunks: Buffer[] = [] + for await (const chunk of requestFile.file.stream as any) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) + } + const streamBuffer = Buffer.concat(chunks) + req = req.send(streamBuffer) + } + } if (this.config.expectedStatus) { req = req.expect(this.config.expectedStatus) @@ -145,13 +193,25 @@ export class ResponseBuilder extends AbstractTestBuilder { headers: this.config.requestHeaders, queryParams: this.config.queryParams, pathParams: this.config.pathParams, - requestBody: this.config.requestBody, + requestBody: this.config.requestFile + ? this.config.requestFile + : this.config.requestBody, }, response: { status: 1, responseBody: null, }, } + + // let requestType: string = "" + // if (this.config.requestFile) { + // requestType = "binary" + // } else if (this.config.requestBody) { + // requestType = "body" + // } else { + // throw new Error("Could not define requestType") + // } + try { const res = await req logToPrint.response = { @@ -171,6 +231,7 @@ export class ResponseBuilder extends AbstractTestBuilder { url: this.url, options: this.config.apiOptions || {}, request: { + file: this.config.requestFile, body: this.config.requestBody, headers: this.prepareHeadersForCollector(this.config.requestHeaders), queryParams: this.config.queryParams, diff --git a/package.json b/package.json index a60d605..9bef2ff 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "prepare": "husky && husky install", "prettier": "prettier \"{lib,__{tests}__}/**/*.{ts,mts}\" --config .prettierrc --write", "prettier:check": "prettier \"{lib,__{tests}__}/**/*.{ts,mts}\" --config .prettierrc --check", - "test": "pnpm build && pnpm test:unit && pnpm test:e2e", + "test": "pnpm build && pnpm --filter example-express test:mocha", "test:unit": "mocha", "test:e2e": "pnpm --filter example-express test && pnpm --filter testframework-compatibility-test test && pnpm --filter example-nestjs test && pnpm --filter example-fastify test && pnpm --filter example-express-ts test", "docs": "pnpm --filter itdoc-doc run start", From 6aae49219cf0627606f926782f6c02b565e2e262 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sat, 4 Oct 2025 17:56:09 +0900 Subject: [PATCH 07/22] write example itdoc for octstream api --- examples/express/__tests__/expressApp.test.js | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/examples/express/__tests__/expressApp.test.js b/examples/express/__tests__/expressApp.test.js index 795ef2c..e9563bc 100644 --- a/examples/express/__tests__/expressApp.test.js +++ b/examples/express/__tests__/expressApp.test.js @@ -538,8 +538,7 @@ describeAPI( fs.writeFileSync(path.join(dir, "example.txt"), "This is an example file.") }) - // with filePath - itDoc("파일 업로드 성공", async () => { + itDoc("파일 업로드 성공 (with filePath)", async () => { await apiDoc .test() .req() @@ -550,10 +549,18 @@ describeAPI( ) .res() .status(HttpStatus.CREATED) + }) + + itDoc("업로드할 파일을 지정하지 않으면 400에러가 뜬다", async () => { + await apiDoc + .test() + .prettyPrint() + .req() + .file() + .res() + .status(HttpStatus.BAD_REQUEST) .body({ - success: true, - message: field("성공 메시지", "File uploaded successfully"), - fileId: field("파일 ID", "file123"), + error: field("에러 메세지", "No file uploaded"), }) }) }, From 8d8ddfe73b20a8e4c648031f2a257067a1ad4022 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sat, 4 Oct 2025 17:57:41 +0900 Subject: [PATCH 08/22] revert package.json to original --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9bef2ff..a60d605 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "prepare": "husky && husky install", "prettier": "prettier \"{lib,__{tests}__}/**/*.{ts,mts}\" --config .prettierrc --write", "prettier:check": "prettier \"{lib,__{tests}__}/**/*.{ts,mts}\" --config .prettierrc --check", - "test": "pnpm build && pnpm --filter example-express test:mocha", + "test": "pnpm build && pnpm test:unit && pnpm test:e2e", "test:unit": "mocha", "test:e2e": "pnpm --filter example-express test && pnpm --filter testframework-compatibility-test test && pnpm --filter example-nestjs test && pnpm --filter example-fastify test && pnpm --filter example-express-ts test", "docs": "pnpm --filter itdoc-doc run start", From 7f8022541af7006726ad87b6ca27b333ea5c6b99 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:06:07 +0900 Subject: [PATCH 09/22] refactor: fileField remove now, just direct pass args with `.req().file(...)` --- examples/express/__tests__/expressApp.test.js | 42 ++++++-- lib/dsl/index.ts | 2 +- lib/dsl/interface/field.ts | 45 -------- lib/dsl/interface/index.ts | 4 +- lib/dsl/test-builders/RequestBuilder.ts | 102 +++++++++++++++--- lib/dsl/test-builders/ResponseBuilder.ts | 9 +- 6 files changed, 131 insertions(+), 73 deletions(-) diff --git a/examples/express/__tests__/expressApp.test.js b/examples/express/__tests__/expressApp.test.js index e9563bc..5e74b59 100644 --- a/examples/express/__tests__/expressApp.test.js +++ b/examples/express/__tests__/expressApp.test.js @@ -1,5 +1,5 @@ const app = require("../expressApp.js") -const { describeAPI, itDoc, HttpStatus, field, fileField, HttpMethod } = require("itdoc") +const { describeAPI, itDoc, HttpStatus, field, HttpMethod } = require("itdoc") const targetApp = app describeAPI( @@ -542,11 +542,41 @@ describeAPI( await apiDoc .test() .req() - .file( - fileField("업로드할 파일", { - path: require("path").join(__dirname, "../tmp/example.txt"), - }), - ) + .file("업로드할 파일", { + path: require("path").join(__dirname, "../tmp/example.txt"), + }) + .res() + .status(HttpStatus.CREATED) + }) + + itDoc("파일 업로드 성공 (with Stream)", async () => { + const fs = require("fs") + const path = require("path") + const filePath = path.join(__dirname, "../tmp/example.txt") + + await apiDoc + .test() + .req() + .file("업로드할 파일", { + stream: fs.createReadStream(filePath), + filename: "example-stream.txt", + }) + .res() + .status(HttpStatus.CREATED) + }) + + itDoc("파일 업로드 성공 (with Buffer)", async () => { + const fs = require("fs") + const path = require("path") + const filePath = path.join(__dirname, "../tmp/example.txt") + + await apiDoc + .test() + .req() + .file("업로드할 파일", { + buffer: fs.readFileSync(filePath), + filename: "example-buffer.txt", + }) .res() .status(HttpStatus.CREATED) }) diff --git a/lib/dsl/index.ts b/lib/dsl/index.ts index e909f8f..5692bb7 100644 --- a/lib/dsl/index.ts +++ b/lib/dsl/index.ts @@ -16,5 +16,5 @@ export { HttpMethod } from "./enums/HttpMethod" export { HttpStatus } from "./enums/HttpStatus" -export { describeAPI, itDoc, field, fileField } from "./interface" +export { describeAPI, itDoc, field } from "./interface" export type { ApiDocOptions } from "./interface/ItdocBuilderEntry" diff --git a/lib/dsl/interface/field.ts b/lib/dsl/interface/field.ts index 9863222..728f5aa 100644 --- a/lib/dsl/interface/field.ts +++ b/lib/dsl/interface/field.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -import fs from "fs" - export type FIELD_TYPES = | string | number @@ -57,49 +55,6 @@ export function field( return { description, example, required } as DSLField } -/** - * DSL File Field creation function - * @param {string} description Field description to be displayed in documentation - * @param {string} path Local file path for the upload - * @param buffer Buffer containing file data - * @param stream Readable stream containing file data - * @param filename (Optional) Filename to be used in the upload (if not provided, the name from filePath will be used) - */ -export function fileField( - description: string, - { - path, - buffer, - stream, - filename, - }: { path?: string; buffer?: Buffer; stream?: NodeJS.ReadableStream; filename?: string }, -): DSLRequestFile { - if (path) { - if (!fs.existsSync(path)) { - throw new Error(`fileField(): path "${path}" does not exist.`) - } - } else if (buffer) { - if (!Buffer.isBuffer(buffer)) { - throw new Error("fileField(): buffer must be a Buffer instance.") - } - } else if (stream) { - if ( - typeof stream !== "object" || - typeof (stream as NodeJS.ReadableStream).pipe !== "function" - ) { - throw new Error("fileField(): stream must be a Readable stream.") - } - } else { - throw new Error("fileField(): provide one of path | buffer | stream.") - } - - return { - description, - file: { path, buffer, stream }, - opts: { contentType: "application/octet-stream", filename }, - } satisfies DSLRequestFile -} - /** * DSL Field type guard * @description diff --git a/lib/dsl/interface/index.ts b/lib/dsl/interface/index.ts index b38767e..f5e240e 100644 --- a/lib/dsl/interface/index.ts +++ b/lib/dsl/interface/index.ts @@ -16,8 +16,8 @@ import { describeAPI } from "./describeAPI" import { itDoc } from "./itDoc" -import { field, fileField, DSLField, DSLRequestFile } from "./field" +import { field, DSLField, DSLRequestFile } from "./field" import { ApiDocOptions } from "./ItdocBuilderEntry" -export { describeAPI, itDoc, field, fileField } +export { describeAPI, itDoc, field } export type { ApiDocOptions, DSLField, DSLRequestFile } diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 8b522bd..a0b1efc 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -21,6 +21,14 @@ import { DSLRequestFile, FIELD_TYPES } from "../interface/field" import { AbstractTestBuilder } from "./AbstractTestBuilder" import logger from "../../config/logger" +interface FileDescriptor { + readonly path?: string + readonly buffer?: Buffer + readonly stream?: NodeJS.ReadableStream + readonly filename?: string + readonly contentType?: string +} + /** * Builder class for setting API request information. */ @@ -57,32 +65,94 @@ export class RequestBuilder extends AbstractTestBuilder { /** * Sets the request body as a raw file (NOT multipart/form-data). * - * - Accepts a {@link DSLRequestFile} containing: - * • `file`: source (exactly one of path | buffer | stream) - * • `opts`: metadata such as contentType (required) and filename (optional) - * - Mutually exclusive with {@link body()}. - * @param requestFile - * @example - * req().file({ - * file: { path: "./fixtures/report.pdf" }, - * opts: { contentType: "application/pdf", filename: "report.pdf" } - * }) + * Two invocation styles are supported: + * 1. Shorthand – `req().file("description", { path | buffer | stream, filename?, contentType? })` + * 2. Advanced – pass a custom {@link DSLRequestFile} object (legacy support). + * + * The request is mutually exclusive with {@link body()}. */ - public file(requestFile: DSLRequestFile): this { - if (this.config.requestHeaders) { - throw new Error("already defined headers. can't use file()") + public file(description: string, descriptor: FileDescriptor): this + public file(requestFile: DSLRequestFile): this + public file(descriptionOrRequest: string | DSLRequestFile, descriptor?: FileDescriptor): this { + const normalized = this.normalizeFileArguments(descriptionOrRequest, descriptor) + return this.applyFile(normalized) + } + + private normalizeFileArguments( + descriptionOrRequest: string | DSLRequestFile | undefined, + descriptor?: FileDescriptor, + ): DSLRequestFile | undefined { + if (typeof descriptionOrRequest !== "string") { + return descriptionOrRequest } - this.config.requestHeaders = { - ...(this.config.requestHeaders ?? {}), - "Content-Type": "application/octet-stream", + if (!descriptor || typeof descriptor !== "object") { + return undefined + } + + const file: DSLRequestFile["file"] = {} + + const { path, buffer, stream } = descriptor + + if (path !== undefined) { + file.path = path + } + if (buffer !== undefined) { + if (!Buffer.isBuffer(buffer)) { + throw new Error("req().file(): buffer must be a Buffer instance.") + } + file.buffer = buffer + } + if (stream !== undefined) { + if (!this.isReadableStream(stream)) { + throw new Error("req().file(): stream must be a readable stream.") + } + file.stream = stream + } + + const providedSources = [file.path, file.buffer, file.stream].filter((value) => value) + if (providedSources.length !== 1) { + throw new Error( + "req().file(): provide exactly one of path | buffer | stream in the descriptor.", + ) + } + + const normalizedContentType = descriptor.contentType ?? "application/octet-stream" + + return { + description: descriptionOrRequest, + file, + opts: descriptor.filename + ? { contentType: normalizedContentType, filename: descriptor.filename } + : { contentType: normalizedContentType }, + } + } + + private isReadableStream(value: unknown): value is NodeJS.ReadableStream { + return ( + !!value && + typeof value === "object" && + typeof (value as NodeJS.ReadableStream).pipe === "function" + ) + } + + private applyFile(requestFile: DSLRequestFile | undefined): this { + if (this.config.requestHeaders) { + throw new Error("already defined headers. can't use file()") } if (!requestFile || typeof requestFile !== "object") { + this.config.requestHeaders = { + "content-type": "application/octet-stream", + } logger.warn("req().file(): provide one of file.path | file.buffer | file.stream.") return this } + this.config.requestHeaders = { + "content-type": requestFile.opts?.contentType ?? "application/octet-stream", + } + const { file } = requestFile const sources = [file.path ? 1 : 0, file.buffer ? 1 : 0, file.stream ? 1 : 0].reduce( diff --git a/lib/dsl/test-builders/ResponseBuilder.ts b/lib/dsl/test-builders/ResponseBuilder.ts index 2fcb355..3c236ef 100644 --- a/lib/dsl/test-builders/ResponseBuilder.ts +++ b/lib/dsl/test-builders/ResponseBuilder.ts @@ -100,6 +100,8 @@ export class ResponseBuilder extends AbstractTestBuilder { if (this.config.requestFile) { const requestFile: DSLRequestFile = this.config.requestFile + const contentType = requestFile.opts?.contentType ?? "application/octet-stream" + if ( !requestFile.file || (!requestFile.file.path && !requestFile.file.buffer && !requestFile.file.stream) @@ -112,7 +114,7 @@ export class ResponseBuilder extends AbstractTestBuilder { (k) => k.toLowerCase() === "content-type", ) if (!hasContentType) { - req.set("Content-Type", "application/octet-stream") + req.set("Content-Type", contentType) } const buf = fs.readFileSync(requestFile.file.path) req = req.send(buf) @@ -123,8 +125,9 @@ export class ResponseBuilder extends AbstractTestBuilder { (k) => k.toLowerCase() === "content-type", ) if (!hasContentType) { - req.set("Content-Type", "application/octet-stream") + req.set("Content-Type", contentType) } + req = req.send(requestFile.file.buffer) } else if (requestFile.file.stream) { const hasContentType = !!this.config.requestHeaders && @@ -132,7 +135,7 @@ export class ResponseBuilder extends AbstractTestBuilder { (k) => k.toLowerCase() === "content-type", ) if (!hasContentType) { - req.set("Content-Type", "application/octet-stream") + req.set("Content-Type", contentType) } const chunks: Buffer[] = [] for await (const chunk of requestFile.file.stream as any) { From 47336b1b17a9490914a49c1d32df7e092a584e03 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:11:24 +0900 Subject: [PATCH 10/22] test refactor --- examples/express/__tests__/expressApp.test.js | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/examples/express/__tests__/expressApp.test.js b/examples/express/__tests__/expressApp.test.js index 5e74b59..a4ea433 100644 --- a/examples/express/__tests__/expressApp.test.js +++ b/examples/express/__tests__/expressApp.test.js @@ -527,23 +527,14 @@ describeAPI( }, targetApp, (apiDoc) => { - before(() => { - // tmp 폴더에 example.txt 파일 생성 - const fs = require("fs") - const path = require("path") - const dir = path.join(__dirname, "../tmp") - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir) - } - fs.writeFileSync(path.join(dir, "example.txt"), "This is an example file.") - }) + const fileToUpload = "../expected/oas.json" itDoc("파일 업로드 성공 (with filePath)", async () => { await apiDoc .test() .req() .file("업로드할 파일", { - path: require("path").join(__dirname, "../tmp/example.txt"), + path: require("path").join(__dirname, fileToUpload), }) .res() .status(HttpStatus.CREATED) @@ -551,8 +542,7 @@ describeAPI( itDoc("파일 업로드 성공 (with Stream)", async () => { const fs = require("fs") - const path = require("path") - const filePath = path.join(__dirname, "../tmp/example.txt") + const filePath = require("path").join(__dirname, fileToUpload) await apiDoc .test() @@ -567,8 +557,7 @@ describeAPI( itDoc("파일 업로드 성공 (with Buffer)", async () => { const fs = require("fs") - const path = require("path") - const filePath = path.join(__dirname, "../tmp/example.txt") + const filePath = require("path").join(__dirname, fileToUpload) await apiDoc .test() From 873cada0a490dc091c73261bdd72d8c3efaf0d23 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:13:12 +0900 Subject: [PATCH 11/22] clean up --- lib/dsl/test-builders/ResponseBuilder.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/lib/dsl/test-builders/ResponseBuilder.ts b/lib/dsl/test-builders/ResponseBuilder.ts index 3c236ef..9a21538 100644 --- a/lib/dsl/test-builders/ResponseBuilder.ts +++ b/lib/dsl/test-builders/ResponseBuilder.ts @@ -206,15 +206,6 @@ export class ResponseBuilder extends AbstractTestBuilder { }, } - // let requestType: string = "" - // if (this.config.requestFile) { - // requestType = "binary" - // } else if (this.config.requestBody) { - // requestType = "body" - // } else { - // throw new Error("Could not define requestType") - // } - try { const res = await req logToPrint.response = { From 8ba0eb8c4e28c336ad2e8517fb318b18ce899956 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:29:00 +0900 Subject: [PATCH 12/22] =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EC=A0=81=EC=9D=B8=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- itdoc-doc/docs/guides/configuration.mdx | 4 +- .../docs/guides/file-related-api-guide.mdx | 77 ++++++++++++++++++ .../current/guides/configuration.mdx | 2 +- .../current/guides/file-related-api-guide.mdx | 79 +++++++++++++++++++ 4 files changed, 159 insertions(+), 3 deletions(-) create mode 100644 itdoc-doc/docs/guides/file-related-api-guide.mdx create mode 100644 itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/file-related-api-guide.mdx diff --git a/itdoc-doc/docs/guides/configuration.mdx b/itdoc-doc/docs/guides/configuration.mdx index 2d6c700..c3373cc 100644 --- a/itdoc-doc/docs/guides/configuration.mdx +++ b/itdoc-doc/docs/guides/configuration.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 9999 toc_max_heading_level: 4 --- @@ -38,4 +38,4 @@ This section provides detailed explanations of each `itdoc` configuration option |---------------|-----------------------------------------------------|----------------------------------------------------------------------| | `baseUrl` | The base URL used for generating links in API docs. | `"http://localhost:8080"` | | `title` | The title displayed in the API documentation. | `"API Document"` | -| `description` | The description displayed in the API documentation. | `"You can change the description by specifying it in package.json."` | +| `description` | The description displayed in the API documentation. | `"You can change the description by specifying it in package.json."` | diff --git a/itdoc-doc/docs/guides/file-related-api-guide.mdx b/itdoc-doc/docs/guides/file-related-api-guide.mdx new file mode 100644 index 0000000..ba9709f --- /dev/null +++ b/itdoc-doc/docs/guides/file-related-api-guide.mdx @@ -0,0 +1,77 @@ +--- +sidebar_position: 4 +--- + +# Working with File APIs + +> This guide explains how to test APIs that upload or download files. + +## Single File Upload (Binary Body) + +`itdoc` handles binary uploads through the `req().file()` DSL. You cannot combine `req().file()` with `req().body()` in the same request. + +**Supported signatures** + +- `req().file("description", { path: string, filename?, contentType? })` +- `req().file("description", { buffer: Buffer, filename?, contentType? })` +- `req().file("description", { stream: Readable, filename?, contentType? })` + +Choose exactly one of `path`, `buffer`, or `stream` to supply the file. The default `contentType` is `application/octet-stream`. + +```ts title="Upload via file path" +await apiDoc + .test() + .req() + .file("File to upload", { + path: path.join(__dirname, "fixtures/sample.bin"), + }) + .res() + .status(HttpStatus.CREATED) +``` + +```ts title="Upload via stream" +await apiDoc + .test() + .req() + .file("File to upload", { + stream: fs.createReadStream(filePath), + filename: "sample.bin", + contentType: "application/pdf", + }) + .res() + .status(HttpStatus.CREATED) +``` + +```ts title="Upload via buffer" +await apiDoc + .test() + .req() + .file("File to upload", { + buffer: fs.readFileSync(filePath), + filename: "sample.bin", + }) + .res() + .status(HttpStatus.CREATED) +``` + +:::tip +Calling `.file()` without a source sends an empty body with only the `Content-Type` header set. This is handy when you need to assert a failure path. + +```js +itDoc("fail when no file is provided", () => { + return apiDoc + .test() + .req() + .file() + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("Error message", "No file uploaded"), + }) +}) +``` +::: + +## Multipart Upload + +> Not supported yet. diff --git a/itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/configuration.mdx b/itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/configuration.mdx index 83b44c6..86a4638 100644 --- a/itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/configuration.mdx +++ b/itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/configuration.mdx @@ -1,5 +1,5 @@ --- -sidebar_position: 4 +sidebar_position: 9999 toc_max_heading_level: 4 --- diff --git a/itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/file-related-api-guide.mdx b/itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/file-related-api-guide.mdx new file mode 100644 index 0000000..88de001 --- /dev/null +++ b/itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/file-related-api-guide.mdx @@ -0,0 +1,79 @@ +--- +sidebar_position: 4 +--- + +# 파일 관련 API 다루기 + +> 이 가이드는 파일 업로드/다운로드와 같은 API를 테스트하는 방법을 설명합니다. + +## 단일 파일 업로드 (Binary Body) + +`itdoc`는 단일 바이너리 업로드를 `req().file()`를 통해 할 수 있습니다. +이때, JSON 본문을 설정하는 `req().body()`와 동시에 사용할 수 없습니다. + +**지원 시그니처** + +- `req().file("설명", { path: string, filename?, contentType? })` +- `req().file("설명", { buffer: Buffer, filename?, contentType? })` +- `req().file("설명", { stream: Readable, filename?, contentType? })` + +`path` · `buffer` · `stream` 중 하나를 선택해 파일을 전달할 수 있습니다. +`contentType` 기본값은 `application/octet-stream`입니다. + +```ts title="파일 경로 업로드" +await apiDoc + .test() + .req() + .file("업로드할 파일", { + path: path.join(__dirname, "fixtures/sample.bin"), + }) + .res() + .status(HttpStatus.CREATED) +``` + +```ts title="스트림 업로드" +await apiDoc + .test() + .req() + .file("업로드할 파일", { + stream: fs.createReadStream(filePath), + filename: "sample.bin", + contentType: "application/pdf", + }) + .res() + .status(HttpStatus.CREATED) +``` + +```ts title="버퍼 업로드" +await apiDoc + .test() + .req() + .file("업로드할 파일", { + buffer: fs.readFileSync(filePath), + filename: "sample.bin", + }) + .res() + .status(HttpStatus.CREATED) +``` + +:::tip +`.file()`만 호출하고 소스를 생략하면 `Content-Type`만 설정된 채 빈 본문이 전송됩니다. 업로드 실패 케이스를 검증할 때 활용할 수 있습니다. + +```js +itDoc("업로드할 파일을 지정하지 않으면 400에러가 뜬다", () => { + return apiDoc + .test() + .req() + .file() + .res() + .status(HttpStatus.BAD_REQUEST) + .body({ + error: field("에러 메세지", "No file uploaded") + }) +} +``` +::: + +## Multipart 파일 업로드 + +> 아직 지원하지 않습니다. From 355fb760d94f03ae752952db2c79b594f7fc9180 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:32:40 +0900 Subject: [PATCH 13/22] review apply. thx rabbit~ --- examples/express/expressApp.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/express/expressApp.js b/examples/express/expressApp.js index 62fa3b3..fe9c8d2 100644 --- a/examples/express/expressApp.js +++ b/examples/express/expressApp.js @@ -253,7 +253,7 @@ app.get("/failed-test", (req, res) => { app.post("/uploads", (req, res) => { if (req.headers["content-type"] !== "application/octet-stream") { - res.status(400).json({ error: "Invalid content type" }) + return res.status(400).json({ error: "Invalid content type" }) } let uploadedBytes = 0 @@ -264,13 +264,13 @@ app.post("/uploads", (req, res) => { req.on("end", () => { if (uploadedBytes === 0) { - res.status(400).json({ error: "No file uploaded" }) + return res.status(400).json({ error: "No file uploaded" }) } - res.status(201).json() + return res.status(201).json() }) req.on("error", () => { - res.status(500).json({ error: "Upload failed" }) + return res.status(500).json({ error: "Upload failed" }) }) }) From a098d369bef6a54bc518b2d07f13f23abb810077 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:35:34 +0900 Subject: [PATCH 14/22] revert it --- .../builders/operation/RequestBodyBuilder.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts b/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts index ac9e693..6cb96a9 100644 --- a/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts +++ b/lib/dsl/generator/builders/operation/RequestBodyBuilder.ts @@ -20,6 +20,7 @@ import { RequestBodyBuilderInterface } from "./interfaces" import { SchemaBuilder } from "../schema" import { UtilityBuilder } from "./UtilityBuilder" import { isDSLField } from "../../../interface/field" +import logger from "../../../../config/logger" /** * Builder class responsible for creating OpenAPI RequestBody objects @@ -78,13 +79,8 @@ export class RequestBodyBuilder implements RequestBodyBuilderInterface { * @returns Content-Type value */ private getContentType(request: TestResult["request"]): string { - if (request.headers) { - // case ignores - const headers = Object.fromEntries( - Object.entries(request.headers).map(([k, v]) => [k.toLowerCase(), v]), - ) - const contentType = headers["content-type"] - + if (request.headers && "content-type" in request.headers) { + const contentType = request.headers["content-type"] if (typeof contentType === "string") { return contentType } else if (isDSLField(contentType) && typeof contentType.example === "string") { @@ -92,6 +88,7 @@ export class RequestBodyBuilder implements RequestBodyBuilderInterface { } } + logger.warn("Content-Type header not found. Falling back to default 'application/json'.") return "application/json" } } From 91bca0569c7f86a74376f4aedf881cdce4d8b3b9 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:37:09 +0900 Subject: [PATCH 15/22] review apply. logic fix - thx rabbit~ --- lib/dsl/test-builders/RequestBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index a0b1efc..573a304 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -55,7 +55,7 @@ export class RequestBuilder extends AbstractTestBuilder { }) this.config.requestHeaders = normalizedHeaders - if (headers["content-type"]) { + if (normalizedHeaders["content-type"]) { throw new Error('You cannot set "Content-Type" header using header().') } this.config.requestHeaders = headers From 070ea6798a1904c5a0bf65a3683ca00fdb1e3f30 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:38:49 +0900 Subject: [PATCH 16/22] logic enhance --- lib/dsl/test-builders/RequestBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 573a304..718b44c 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -188,7 +188,7 @@ export class RequestBuilder extends AbstractTestBuilder { * @returns {this} Request builder instance */ public body(body: Record | FIELD_TYPES>): this { - if (this.config.requestBody) { + if (this.config.requestBody || this.config.requestFile) { throw new Error( [ "❌ Conflict: request body has already been set using .body().", From b869307b0265e8010b6bf4de5dfb8ae889182204 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 19:39:25 +0900 Subject: [PATCH 17/22] review apply --- lib/dsl/test-builders/ResponseBuilder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dsl/test-builders/ResponseBuilder.ts b/lib/dsl/test-builders/ResponseBuilder.ts index 9a21538..4d6101d 100644 --- a/lib/dsl/test-builders/ResponseBuilder.ts +++ b/lib/dsl/test-builders/ResponseBuilder.ts @@ -116,7 +116,7 @@ export class ResponseBuilder extends AbstractTestBuilder { if (!hasContentType) { req.set("Content-Type", contentType) } - const buf = fs.readFileSync(requestFile.file.path) + const buf = await fs.promises.readFile(requestFile.file.path) req = req.send(buf) } else if (requestFile.file.buffer) { const hasContentType = From a15c5ba54237bc04c5a3e5ebf76d8ac92df34b21 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 20:00:20 +0900 Subject: [PATCH 18/22] fix validate --- lib/dsl/test-builders/RequestBuilder.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 718b44c..4eb1bba 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -137,8 +137,12 @@ export class RequestBuilder extends AbstractTestBuilder { } private applyFile(requestFile: DSLRequestFile | undefined): this { - if (this.config.requestHeaders) { - throw new Error("already defined headers. can't use file()") + const existingHeaders = this.config.requestHeaders ?? {} + const hasContentType = Object.keys(existingHeaders).some( + (key) => key.toLowerCase() === "content-type", + ) + if (hasContentType) { + throw new Error('You cannot set "Content-Type" header when using file().') } if (!requestFile || typeof requestFile !== "object") { From 3b5fa8c7252e083048c6e05dfd799e2511cd9ab5 Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 20:02:09 +0900 Subject: [PATCH 19/22] apply review : type specific --- lib/dsl/generator/types/TestResult.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/dsl/generator/types/TestResult.ts b/lib/dsl/generator/types/TestResult.ts index f8014f0..0894708 100644 --- a/lib/dsl/generator/types/TestResult.ts +++ b/lib/dsl/generator/types/TestResult.ts @@ -15,7 +15,7 @@ */ import { HttpMethod } from "../../enums" -import { ApiDocOptions } from "../../interface" +import { ApiDocOptions, DSLRequestFile } from "../../interface" /** * Test result interface @@ -41,7 +41,7 @@ export interface TestResult { url: string options: ApiDocOptions request: { - file?: unknown + file?: DSLRequestFile body?: unknown headers?: Record queryParams?: Record From 5af6b83e121da24b917f4384e2f2ec27a668fa44 Mon Sep 17 00:00:00 2001 From: Penek Date: Sat, 4 Oct 2025 18:57:20 +0900 Subject: [PATCH 20/22] fix: ensure header keys are case-insensitive (#251) * fix: apply header key normalized to lowercase * update expected oas.json --- examples/express/expected/oas.json | 89 +++++++++++++++++----- examples/express/package.json | 2 +- itdoc-doc/docs/api-reference/interface.mdx | 20 ++++- lib/config/logger.ts | 2 - package.json | 4 +- 5 files changed, 88 insertions(+), 29 deletions(-) diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index 22475a0..f196198 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -601,11 +601,7 @@ "tags": ["Secret"], "description": "비밀 API 입니다. 인증이 필요합니다.", "operationId": "getSecret", - "security": [ - { - "BearerAuth": [] - } - ], + "security": [{}], "responses": { "200": { "description": "인증 토큰이 있으면 접근할 수 있다.", @@ -646,7 +642,7 @@ "operationId": "postOrders", "parameters": [ { - "name": "x-request-id", + "name": "X-Request-ID", "in": "header", "schema": { "type": "string", @@ -865,11 +861,7 @@ }, "required": true }, - "security": [ - { - "BearerAuth": [] - } - ], + "security": [{}], "responses": { "201": { "description": "복잡한 주문 생성 성공", @@ -1188,7 +1180,7 @@ "required": false }, { - "name": "accept", + "name": "Accept", "in": "header", "schema": { "type": "string", @@ -1197,7 +1189,7 @@ "required": false }, { - "name": "accept-language", + "name": "Accept-Language", "in": "header", "schema": { "type": "string", @@ -1299,14 +1291,69 @@ } } } - } - }, - "components": { - "securitySchemes": { - "BearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" + }, + "/uploads": { + "post": { + "summary": "파일 업로드 API", + "tags": ["File"], + "description": "파일을 업로드합니다.", + "operationId": "postUploads", + "parameters": [ + { + "name": "content-type", + "in": "header", + "schema": { + "type": "string", + "example": "application/octet-stream" + }, + "required": false + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + }, + "required": true + }, + "security": [{}], + "responses": { + "201": { + "description": "파일 업로드 성공 (with filePath)" + }, + "400": { + "description": "업로드할 파일을 지정하지 않으면 400에러가 뜬다", + "content": { + "application/json; charset=utf-8": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "No file uploaded", + "description": "에러 메세지" + } + }, + "required": ["error"] + }, + "examples": { + "업로드할 파일을 지정하지 않으면 400에러가 뜬다": { + "value": { + "error": { + "message": "업로드할 파일을 지정하지 않으면 400에러가 뜬다", + "code": "ERROR_400" + } + } + } + } + } + } + } + } } } }, diff --git a/examples/express/package.json b/examples/express/package.json index 2a78e77..4f9ddee 100644 --- a/examples/express/package.json +++ b/examples/express/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "test": "node ./scripts/run-tests-and-validate.js", + "test": "echo \"run test as mocha\" && mocha ./__tests__", "test:jest": "echo \"run test as jest\" && cross-env NODE_OPTIONS='--experimental-vm-modules' jest", "test:mocha": "echo \"run test as mocha\" && mocha ./__tests__" }, diff --git a/itdoc-doc/docs/api-reference/interface.mdx b/itdoc-doc/docs/api-reference/interface.mdx index 642efc4..b4ad2e1 100644 --- a/itdoc-doc/docs/api-reference/interface.mdx +++ b/itdoc-doc/docs/api-reference/interface.mdx @@ -213,11 +213,25 @@ apiDoc ### req() Defines values used in API requests. - - `body(body: object)`: Set request body - - `header(headers: object)`: Set request headers + - `body(body: object)`: Set JSON request body + - `file(description: string, descriptor: { path?: string; buffer?: Buffer; stream?: Readable; filename?: string; contentType?: string })`: Send a single binary payload. Provide exactly one of `path`, `buffer`, or `stream`. Mutually exclusive with `body()`. + - `file(requestFile: DSLRequestFile)`: Advanced form for custom integrations (expects the same structure as the descriptor above). + - `header(headers: object)`: Set request headers (Content-Type is managed automatically for `.file()`). - `pathParam(params: object)`: Set path parameters - `queryParam(params: object)`: Set query parameters - - `expectStatus(status: HttpStatus)`: Set expected response status (**Required**) + +```ts +apiDoc + .test() + .req() + .file("업로드할 파일", { + stream: fs.createReadStream(filePath), + filename: "sample.bin", + contentType: "application/octet-stream", + }) + .res() + .status(HttpStatus.CREATED) +``` ### res() diff --git a/lib/config/logger.ts b/lib/config/logger.ts index ad2b36e..04c47b1 100644 --- a/lib/config/logger.ts +++ b/lib/config/logger.ts @@ -14,8 +14,6 @@ * 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" diff --git a/package.json b/package.json index a60d605..af82b22 100644 --- a/package.json +++ b/package.json @@ -57,8 +57,8 @@ "prettier:check": "prettier \"{lib,__{tests}__}/**/*.{ts,mts}\" --config .prettierrc --check", "test": "pnpm build && pnpm test:unit && pnpm test:e2e", "test:unit": "mocha", - "test:e2e": "pnpm --filter example-express test && pnpm --filter testframework-compatibility-test test && pnpm --filter example-nestjs test && pnpm --filter example-fastify test && pnpm --filter example-express-ts test", - "docs": "pnpm --filter itdoc-doc run start", + "test:e2e": "pnpm --filter example-express test", + "docs": "pnpm --filter itdoc-doc run serve", "docs-build": "pnpm --filter itdoc-doc run build" }, "lint-staged": { From 042c3c5973af29350179da338853e2e5a1c604cd Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 20:07:57 +0900 Subject: [PATCH 21/22] revert package.json --- examples/express/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/express/package.json b/examples/express/package.json index 4f9ddee..2a78e77 100644 --- a/examples/express/package.json +++ b/examples/express/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "test": "echo \"run test as mocha\" && mocha ./__tests__", + "test": "node ./scripts/run-tests-and-validate.js", "test:jest": "echo \"run test as jest\" && cross-env NODE_OPTIONS='--experimental-vm-modules' jest", "test:mocha": "echo \"run test as mocha\" && mocha ./__tests__" }, From 9e915a932188a28e4991468b4d395ab7183a42ec Mon Sep 17 00:00:00 2001 From: PENEKhun Date: Sun, 5 Oct 2025 20:09:50 +0900 Subject: [PATCH 22/22] fix logic error --- examples/express/expected/oas.json | 27 ++++++++++++++++++++----- lib/dsl/test-builders/RequestBuilder.ts | 1 - 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/examples/express/expected/oas.json b/examples/express/expected/oas.json index f196198..696992e 100644 --- a/examples/express/expected/oas.json +++ b/examples/express/expected/oas.json @@ -601,7 +601,11 @@ "tags": ["Secret"], "description": "비밀 API 입니다. 인증이 필요합니다.", "operationId": "getSecret", - "security": [{}], + "security": [ + { + "BearerAuth": [] + } + ], "responses": { "200": { "description": "인증 토큰이 있으면 접근할 수 있다.", @@ -642,7 +646,7 @@ "operationId": "postOrders", "parameters": [ { - "name": "X-Request-ID", + "name": "x-request-id", "in": "header", "schema": { "type": "string", @@ -861,7 +865,11 @@ }, "required": true }, - "security": [{}], + "security": [ + { + "BearerAuth": [] + } + ], "responses": { "201": { "description": "복잡한 주문 생성 성공", @@ -1180,7 +1188,7 @@ "required": false }, { - "name": "Accept", + "name": "accept", "in": "header", "schema": { "type": "string", @@ -1189,7 +1197,7 @@ "required": false }, { - "name": "Accept-Language", + "name": "accept-language", "in": "header", "schema": { "type": "string", @@ -1357,5 +1365,14 @@ } } }, + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, "security": [{}] } diff --git a/lib/dsl/test-builders/RequestBuilder.ts b/lib/dsl/test-builders/RequestBuilder.ts index 4eb1bba..5bd2564 100644 --- a/lib/dsl/test-builders/RequestBuilder.ts +++ b/lib/dsl/test-builders/RequestBuilder.ts @@ -58,7 +58,6 @@ export class RequestBuilder extends AbstractTestBuilder { if (normalizedHeaders["content-type"]) { throw new Error('You cannot set "Content-Type" header using header().') } - this.config.requestHeaders = headers return this }