Skip to content

Commit aecac7d

Browse files
authored
feat: support file upload via application/octet-stream (#248)
* create dsl req().file() for upload single file api * create fileField dsl sample. * create examples/express for testing file upload api * sample tests * add some validate fileField * update * write example itdoc for octstream api * revert package.json to original * refactor: fileField remove now, just direct pass args with `.req().file(...)` * test refactor * clean up * 기본적인 문서 작성 * review apply. thx rabbit~ * revert it * review apply. logic fix - thx rabbit~ * logic enhance * review apply * fix validate * apply review : type specific * fix: ensure header keys are case-insensitive (#251) * fix: apply header key normalized to lowercase * update expected oas.json * revert package.json * fix logic error
1 parent 366cdb0 commit aecac7d

File tree

18 files changed

+585
-32
lines changed

18 files changed

+585
-32
lines changed

examples/express/__tests__/expressApp.test.js

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,7 @@ describeAPI(
493493
})
494494
},
495495
)
496+
496497
describeAPI(
497498
HttpMethod.GET,
498499
"/failed-test",
@@ -515,3 +516,71 @@ describeAPI(
515516
})
516517
},
517518
)
519+
520+
describeAPI(
521+
HttpMethod.POST,
522+
"/uploads",
523+
{
524+
summary: "파일 업로드 API",
525+
tag: "File",
526+
description: "파일을 업로드합니다.",
527+
},
528+
targetApp,
529+
(apiDoc) => {
530+
const fileToUpload = "../expected/oas.json"
531+
532+
itDoc("파일 업로드 성공 (with filePath)", async () => {
533+
await apiDoc
534+
.test()
535+
.req()
536+
.file("업로드할 파일", {
537+
path: require("path").join(__dirname, fileToUpload),
538+
})
539+
.res()
540+
.status(HttpStatus.CREATED)
541+
})
542+
543+
itDoc("파일 업로드 성공 (with Stream)", async () => {
544+
const fs = require("fs")
545+
const filePath = require("path").join(__dirname, fileToUpload)
546+
547+
await apiDoc
548+
.test()
549+
.req()
550+
.file("업로드할 파일", {
551+
stream: fs.createReadStream(filePath),
552+
filename: "example-stream.txt",
553+
})
554+
.res()
555+
.status(HttpStatus.CREATED)
556+
})
557+
558+
itDoc("파일 업로드 성공 (with Buffer)", async () => {
559+
const fs = require("fs")
560+
const filePath = require("path").join(__dirname, fileToUpload)
561+
562+
await apiDoc
563+
.test()
564+
.req()
565+
.file("업로드할 파일", {
566+
buffer: fs.readFileSync(filePath),
567+
filename: "example-buffer.txt",
568+
})
569+
.res()
570+
.status(HttpStatus.CREATED)
571+
})
572+
573+
itDoc("업로드할 파일을 지정하지 않으면 400에러가 뜬다", async () => {
574+
await apiDoc
575+
.test()
576+
.prettyPrint()
577+
.req()
578+
.file()
579+
.res()
580+
.status(HttpStatus.BAD_REQUEST)
581+
.body({
582+
error: field("에러 메세지", "No file uploaded"),
583+
})
584+
})
585+
},
586+
)

examples/express/expected/oas.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1299,6 +1299,70 @@
12991299
}
13001300
}
13011301
}
1302+
},
1303+
"/uploads": {
1304+
"post": {
1305+
"summary": "파일 업로드 API",
1306+
"tags": ["File"],
1307+
"description": "파일을 업로드합니다.",
1308+
"operationId": "postUploads",
1309+
"parameters": [
1310+
{
1311+
"name": "content-type",
1312+
"in": "header",
1313+
"schema": {
1314+
"type": "string",
1315+
"example": "application/octet-stream"
1316+
},
1317+
"required": false
1318+
}
1319+
],
1320+
"requestBody": {
1321+
"content": {
1322+
"application/octet-stream": {
1323+
"schema": {
1324+
"type": "string",
1325+
"format": "binary"
1326+
}
1327+
}
1328+
},
1329+
"required": true
1330+
},
1331+
"security": [{}],
1332+
"responses": {
1333+
"201": {
1334+
"description": "파일 업로드 성공 (with filePath)"
1335+
},
1336+
"400": {
1337+
"description": "업로드할 파일을 지정하지 않으면 400에러가 뜬다",
1338+
"content": {
1339+
"application/json; charset=utf-8": {
1340+
"schema": {
1341+
"type": "object",
1342+
"properties": {
1343+
"error": {
1344+
"type": "string",
1345+
"example": "No file uploaded",
1346+
"description": "에러 메세지"
1347+
}
1348+
},
1349+
"required": ["error"]
1350+
},
1351+
"examples": {
1352+
"업로드할 파일을 지정하지 않으면 400에러가 뜬다": {
1353+
"value": {
1354+
"error": {
1355+
"message": "업로드할 파일을 지정하지 않으면 400에러가 뜬다",
1356+
"code": "ERROR_400"
1357+
}
1358+
}
1359+
}
1360+
}
1361+
}
1362+
}
1363+
}
1364+
}
1365+
}
13021366
}
13031367
},
13041368
"components": {

examples/express/expressApp.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,4 +251,27 @@ app.get("/failed-test", (req, res) => {
251251
})
252252
})
253253

254+
app.post("/uploads", (req, res) => {
255+
if (req.headers["content-type"] !== "application/octet-stream") {
256+
return res.status(400).json({ error: "Invalid content type" })
257+
}
258+
259+
let uploadedBytes = 0
260+
261+
req.on("data", (chunk) => {
262+
uploadedBytes += chunk.length
263+
})
264+
265+
req.on("end", () => {
266+
if (uploadedBytes === 0) {
267+
return res.status(400).json({ error: "No file uploaded" })
268+
}
269+
return res.status(201).json()
270+
})
271+
272+
req.on("error", () => {
273+
return res.status(500).json({ error: "Upload failed" })
274+
})
275+
})
276+
254277
module.exports = app

itdoc-doc/docs/api-reference/interface.mdx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,11 +213,25 @@ apiDoc
213213
### req()
214214

215215
Defines values used in API requests.
216-
- `body(body: object)`: Set request body
217-
- `header(headers: object)`: Set request headers
216+
- `body(body: object)`: Set JSON request body
217+
- `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()`.
218+
- `file(requestFile: DSLRequestFile)`: Advanced form for custom integrations (expects the same structure as the descriptor above).
219+
- `header(headers: object)`: Set request headers (Content-Type is managed automatically for `.file()`).
218220
- `pathParam(params: object)`: Set path parameters
219221
- `queryParam(params: object)`: Set query parameters
220-
- `expectStatus(status: HttpStatus)`: Set expected response status (**Required**)
222+
223+
```ts
224+
apiDoc
225+
.test()
226+
.req()
227+
.file("업로드할 파일", {
228+
stream: fs.createReadStream(filePath),
229+
filename: "sample.bin",
230+
contentType: "application/octet-stream",
231+
})
232+
.res()
233+
.status(HttpStatus.CREATED)
234+
```
221235

222236
### res()
223237

itdoc-doc/docs/guides/configuration.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 4
2+
sidebar_position: 9999
33
toc_max_heading_level: 4
44
---
55

@@ -38,4 +38,4 @@ This section provides detailed explanations of each `itdoc` configuration option
3838
|---------------|-----------------------------------------------------|----------------------------------------------------------------------|
3939
| `baseUrl` | The base URL used for generating links in API docs. | `"http://localhost:8080"` |
4040
| `title` | The title displayed in the API documentation. | `"API Document"` |
41-
| `description` | The description displayed in the API documentation. | `"You can change the description by specifying it in package.json."` |
41+
| `description` | The description displayed in the API documentation. | `"You can change the description by specifying it in package.json."` |
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
---
2+
sidebar_position: 4
3+
---
4+
5+
# Working with File APIs
6+
7+
> This guide explains how to test APIs that upload or download files.
8+
9+
## Single File Upload (Binary Body)
10+
11+
`itdoc` handles binary uploads through the `req().file()` DSL. You cannot combine `req().file()` with `req().body()` in the same request.
12+
13+
**Supported signatures**
14+
15+
- `req().file("description", { path: string, filename?, contentType? })`
16+
- `req().file("description", { buffer: Buffer, filename?, contentType? })`
17+
- `req().file("description", { stream: Readable, filename?, contentType? })`
18+
19+
Choose exactly one of `path`, `buffer`, or `stream` to supply the file. The default `contentType` is `application/octet-stream`.
20+
21+
```ts title="Upload via file path"
22+
await apiDoc
23+
.test()
24+
.req()
25+
.file("File to upload", {
26+
path: path.join(__dirname, "fixtures/sample.bin"),
27+
})
28+
.res()
29+
.status(HttpStatus.CREATED)
30+
```
31+
32+
```ts title="Upload via stream"
33+
await apiDoc
34+
.test()
35+
.req()
36+
.file("File to upload", {
37+
stream: fs.createReadStream(filePath),
38+
filename: "sample.bin",
39+
contentType: "application/pdf",
40+
})
41+
.res()
42+
.status(HttpStatus.CREATED)
43+
```
44+
45+
```ts title="Upload via buffer"
46+
await apiDoc
47+
.test()
48+
.req()
49+
.file("File to upload", {
50+
buffer: fs.readFileSync(filePath),
51+
filename: "sample.bin",
52+
})
53+
.res()
54+
.status(HttpStatus.CREATED)
55+
```
56+
57+
:::tip
58+
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.
59+
60+
```js
61+
itDoc("fail when no file is provided", () => {
62+
return apiDoc
63+
.test()
64+
.req()
65+
.file()
66+
.res()
67+
.status(HttpStatus.BAD_REQUEST)
68+
.body({
69+
error: field("Error message", "No file uploaded"),
70+
})
71+
})
72+
```
73+
:::
74+
75+
## Multipart Upload
76+
77+
> Not supported yet.

itdoc-doc/i18n/ko/docusaurus-plugin-content-docs/current/guides/configuration.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
sidebar_position: 4
2+
sidebar_position: 9999
33
toc_max_heading_level: 4
44
---
55

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
---
2+
sidebar_position: 4
3+
---
4+
5+
# 파일 관련 API 다루기
6+
7+
> 이 가이드는 파일 업로드/다운로드와 같은 API를 테스트하는 방법을 설명합니다.
8+
9+
## 단일 파일 업로드 (Binary Body)
10+
11+
`itdoc`는 단일 바이너리 업로드를 `req().file()`를 통해 할 수 있습니다.
12+
이때, JSON 본문을 설정하는 `req().body()`와 동시에 사용할 수 없습니다.
13+
14+
**지원 시그니처**
15+
16+
- `req().file("설명", { path: string, filename?, contentType? })`
17+
- `req().file("설명", { buffer: Buffer, filename?, contentType? })`
18+
- `req().file("설명", { stream: Readable, filename?, contentType? })`
19+
20+
`path` · `buffer` · `stream` 중 하나를 선택해 파일을 전달할 수 있습니다.
21+
`contentType` 기본값은 `application/octet-stream`입니다.
22+
23+
```ts title="파일 경로 업로드"
24+
await apiDoc
25+
.test()
26+
.req()
27+
.file("업로드할 파일", {
28+
path: path.join(__dirname, "fixtures/sample.bin"),
29+
})
30+
.res()
31+
.status(HttpStatus.CREATED)
32+
```
33+
34+
```ts title="스트림 업로드"
35+
await apiDoc
36+
.test()
37+
.req()
38+
.file("업로드할 파일", {
39+
stream: fs.createReadStream(filePath),
40+
filename: "sample.bin",
41+
contentType: "application/pdf",
42+
})
43+
.res()
44+
.status(HttpStatus.CREATED)
45+
```
46+
47+
```ts title="버퍼 업로드"
48+
await apiDoc
49+
.test()
50+
.req()
51+
.file("업로드할 파일", {
52+
buffer: fs.readFileSync(filePath),
53+
filename: "sample.bin",
54+
})
55+
.res()
56+
.status(HttpStatus.CREATED)
57+
```
58+
59+
:::tip
60+
`.file()`만 호출하고 소스를 생략하면 `Content-Type`만 설정된 채 빈 본문이 전송됩니다. 업로드 실패 케이스를 검증할 때 활용할 수 있습니다.
61+
62+
```js
63+
itDoc("업로드할 파일을 지정하지 않으면 400에러가 뜬다", () => {
64+
return apiDoc
65+
.test()
66+
.req()
67+
.file()
68+
.res()
69+
.status(HttpStatus.BAD_REQUEST)
70+
.body({
71+
error: field("에러 메세지", "No file uploaded")
72+
})
73+
}
74+
```
75+
:::
76+
77+
## Multipart 파일 업로드
78+
79+
> 아직 지원하지 않습니다.

lib/config/logger.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@
1414
* limitations under the License.
1515
*/
1616

17-
/* eslint-disable no-console */
18-
1917
import { ConsolaReporter, createConsola, LogObject, consola as defaultConsola } from "consola"
2018
import chalk from "chalk"
2119
import { LoggerInterface } from "./LoggerInterface"

0 commit comments

Comments
 (0)