From e218af1ab9f70123edc84ea8a83c804d6b52f323 Mon Sep 17 00:00:00 2001 From: Jaehyun Yoon Date: Wed, 1 Jul 2026 21:55:49 +0900 Subject: [PATCH 1/4] feat: add source decorator to bind multipart/form-data files --- packages/express-cargo/src/binding.ts | 35 +++- packages/express-cargo/src/fileHandler.ts | 52 ++++++ packages/express-cargo/src/index.ts | 1 + packages/express-cargo/src/source.ts | 27 +++ packages/express-cargo/src/types.ts | 3 +- .../tests/binding/fileBinding.test.ts | 170 ++++++++++++++++++ 6 files changed, 285 insertions(+), 3 deletions(-) create mode 100644 packages/express-cargo/src/fileHandler.ts create mode 100644 packages/express-cargo/tests/binding/fileBinding.test.ts diff --git a/packages/express-cargo/src/binding.ts b/packages/express-cargo/src/binding.ts index e8ac023..e0d8823 100644 --- a/packages/express-cargo/src/binding.ts +++ b/packages/express-cargo/src/binding.ts @@ -4,6 +4,7 @@ import { AnalysisResult, BindContext, BindSources, ClassConstructor } from './ty import { CargoFieldError, CargoValidationError, CargoTransformFieldError, Source, TypeResolver, TypeThunk, TypeOptions } from './types' import { CargoClassMetadata, CargoFieldMetadata } from './metadata' import { getCargoErrorHandler } from './errorHandler' +import { getCargoFileLocator, CargoFile } from './fileHandler' import { validateAnalysis } from './rules' import { isClass, isUserDefinedClass } from './utils' import { analyzeCargoSchema } from './analysis' @@ -253,10 +254,16 @@ function bindSource({ metaClass, targetObject, sources, errors, sourceKey }: Bin const key = getFieldKey(meta, sourceKey, errors) if (!key) continue - let value const currentSource = meta.getSource() - const currentSourceData = sources[currentSource as keyof BindSources] + // Uploaded files share one map; `@File` takes the first entry, `@Files` takes them all. + if (currentSource === 'file' || currentSource === 'files') { + bindFile(meta, property, key, sources.file?.[key], currentSource === 'files', targetObject, errors, sourceKey) + continue + } + + let value + const currentSourceData = sources[currentSource as keyof BindSources] if (currentSourceData) { value = currentSourceData[key] } @@ -272,6 +279,27 @@ function bindSource({ metaClass, targetObject, sources, errors, sourceKey }: Bin } } +// Binds an uploaded file field. `@Files` receives the whole array; `@File` takes the first entry. +function bindFile( + meta: CargoFieldMetadata, + property: string | symbol, + key: string, + files: CargoFile[] | undefined, + multiple: boolean, + targetObject: any, + errors: CargoFieldError[], + sourceKey: string, +): void { + const value = files && files.length > 0 ? (multiple ? files : files[0]) : undefined + + if (handleMissing(meta, property, key, value, targetObject, errors, sourceKey)) { + return + } + + targetObject[property] = value + validateField(meta, property, targetObject, errors) +} + function bindVirtual({ metaClass, targetObject, errors, sourceKey }: BindContext): void { for (const property of metaClass.getVirtualFieldList()) { const meta = metaClass.getFieldMetadata(property) @@ -328,6 +356,8 @@ export function bindingCargo(cargoClass: ClassConstructo return (req, res, next) => { try { const errors: CargoFieldError[] = [] + // Normalize uploaded files once; @File and @Files share the same map. + const uploadedFiles = getCargoFileLocator()(req) const sources = { req: req, body: req.body, @@ -335,6 +365,7 @@ export function bindingCargo(cargoClass: ClassConstructo params: req.params, header: req.headers, session: (req as any).session, + file: uploadedFiles, } const cargo = bindObject(cargoClass, result.rootMeta, sources, errors, result) diff --git a/packages/express-cargo/src/fileHandler.ts b/packages/express-cargo/src/fileHandler.ts new file mode 100644 index 0000000..1bacb8e --- /dev/null +++ b/packages/express-cargo/src/fileHandler.ts @@ -0,0 +1,52 @@ +import { Request } from 'express' + +/** An uploaded file as produced by a multipart parser (e.g. multer's `Express.Multer.File`). Bound as-is. */ +export type CargoFile = any + +/** + * Locates uploaded files on the request, keyed by form field name. + * Only the location is parser-specific; the file objects are returned untouched. + */ +export type FileLocator = (req: Request) => Record + +/** Default locator for multer's `req.file` (single) and `req.files` (array or fielded object). */ +const multerFileLocator: FileLocator = (req: Request) => { + const map: Record = {} + const push = (name: string, file: CargoFile) => { + ;(map[name] ??= []).push(file) + } + + const single = (req as any).file + if (single) push(single.fieldname, single) + + // upload.array()/any() → File[]; upload.fields() → { field: File[] } + const files = (req as any).files + if (Array.isArray(files)) { + for (const file of files) push(file.fieldname, file) + } else if (files) { + for (const name of Object.keys(files)) { + map[name] = files[name] + } + } + + return map +} + +let globalFileLocator: FileLocator = multerFileLocator + +/** + * Overrides the global file locator used during binding. + * Only needed for parsers whose request shape differs from multer's. + * @param locator - Returns uploaded files keyed by field name. + */ +export function setCargoFileLocator(locator: FileLocator): void { + globalFileLocator = locator +} + +/** + * Retrieves the currently configured global file locator. + * @returns The current file locator (defaults to multer support). + */ +export function getCargoFileLocator(): FileLocator { + return globalFileLocator +} diff --git a/packages/express-cargo/src/index.ts b/packages/express-cargo/src/index.ts index bfe2b74..e34ddd8 100644 --- a/packages/express-cargo/src/index.ts +++ b/packages/express-cargo/src/index.ts @@ -5,5 +5,6 @@ export * from './validator' export * from './decorators' export * from './transform' export * from './errorHandler' +export * from './fileHandler' export * from './enum' export { CargoSchemaError } from './rules/errors' diff --git a/packages/express-cargo/src/source.ts b/packages/express-cargo/src/source.ts index 80a3042..b3ee56c 100644 --- a/packages/express-cargo/src/source.ts +++ b/packages/express-cargo/src/source.ts @@ -95,3 +95,30 @@ export const Header = createSourceDecorator('header') * ``` */ export const Session = createSourceDecorator('session') + +/** + * Extracts a single uploaded file from a `multipart/form-data` request. + * The file must already be parsed onto the request (multer by default) and is bound as-is. + * @param key - Optional form field name. Defaults to the property name. + * @example + * ```ts + * class UploadExample { + * @File() + * avatar: Express.Multer.File; + * } + * ``` + */ +export const File = createSourceDecorator('file') + +/** + * Extracts all uploaded files sharing a field name as an array. + * @param key - Optional form field name. Defaults to the property name. + * @example + * ```ts + * class UploadExample { + * @Files('photos') + * gallery: Express.Multer.File[]; + * } + * ``` + */ +export const Files = createSourceDecorator('files') diff --git a/packages/express-cargo/src/types.ts b/packages/express-cargo/src/types.ts index 4093905..035fd2c 100644 --- a/packages/express-cargo/src/types.ts +++ b/packages/express-cargo/src/types.ts @@ -9,7 +9,7 @@ import type { CargoClassMetadata } from './metadata' * - `header`: req.headers * - `session`: req.session */ -export type Source = 'body' | 'query' | 'params' | 'header' | 'session' +export type Source = 'body' | 'query' | 'params' | 'header' | 'session' | 'file' | 'files' /** * Represents a class constructor. @@ -201,6 +201,7 @@ export type BindSources = { params: any header: any session: any + file: any } export type BindContext = { diff --git a/packages/express-cargo/tests/binding/fileBinding.test.ts b/packages/express-cargo/tests/binding/fileBinding.test.ts new file mode 100644 index 0000000..6f86798 --- /dev/null +++ b/packages/express-cargo/tests/binding/fileBinding.test.ts @@ -0,0 +1,170 @@ +import { bindingCargo, Body, File, Files, Optional, getCargo, CargoValidationError, setCargoFileLocator, getCargoFileLocator, FileLocator } from '../../src' +import { makeMockReq, makeMockRes, makeNext } from './testUtils' + +/** Builds a multer-like file object. */ +function mockFile(fieldname: string, originalname: string, extra: Record = {}) { + return { fieldname, originalname, mimetype: 'image/png', size: 1024, buffer: Buffer.from('data'), ...extra } +} + +describe('file source binding', () => { + const defaultLocator = getCargoFileLocator() + + afterEach(() => { + // Restore the default locator so custom-locator tests don't leak. + setCargoFileLocator(defaultLocator) + }) + + it('binds a single uploaded file from req.file (upload.single)', () => { + class UploadDTO { + @Body('title') title!: string + @File() avatar!: unknown + } + + const file = mockFile('avatar', 'me.png') + const req = makeMockReq({ body: { title: 'hi' }, file } as object) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(UploadDTO)(req, res, next) + + expect(next).toHaveBeenCalledWith() + const dto = getCargo(req) + expect(dto.title).toBe('hi') + // Same reference → the file object is passed through untouched. + expect(dto.avatar).toBe(file) + }) + + it('binds every file with @Files (upload.array)', () => { + class GalleryDTO { + @Files('photos') photos!: unknown[] + } + + const f1 = mockFile('photos', '1.png') + const f2 = mockFile('photos', '2.png') + const req = makeMockReq({ files: [f1, f2] } as object) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(GalleryDTO)(req, res, next) + + const dto = getCargo(req) + expect(dto.photos).toEqual([f1, f2]) + }) + + it('binds files from the fielded object form (upload.fields)', () => { + class ProfileDTO { + @File() avatar!: unknown + @Files('gallery') gallery!: unknown[] + } + + const avatar = mockFile('avatar', 'a.png') + const g1 = mockFile('gallery', 'g1.png') + const req = makeMockReq({ files: { avatar: [avatar], gallery: [g1] } } as object) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(ProfileDTO)(req, res, next) + + const dto = getCargo(req) + expect(dto.avatar).toBe(avatar) + expect(dto.gallery).toEqual([g1]) + }) + + it('preserves the parser-produced shape (no transformation, e.g. diskStorage path)', () => { + class FileDTO { + @File() file!: unknown + } + + const diskFile = mockFile('file', 'x.png', { path: '/tmp/x.png', buffer: undefined }) + const req = makeMockReq({ file: diskFile } as object) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(FileDTO)(req, res, next) + + const dto = getCargo(req) as { file: Record } + expect(dto.file).toBe(diskFile) + expect(dto.file.path).toBe('/tmp/x.png') + }) + + it('sets an optional file to null when it is missing', () => { + class OptUploadDTO { + @File() @Optional() avatar?: unknown + } + + const req = makeMockReq({}) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(OptUploadDTO)(req, res, next) + + expect(next).toHaveBeenCalledWith() + const dto = getCargo(req) + expect(dto.avatar).toBeNull() + }) + + it('raises a required error when a mandatory file is missing', () => { + class RequiredUploadDTO { + @File() avatar!: unknown + } + + const req = makeMockReq({}) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(RequiredUploadDTO)(req, res, next) + + const err = next.mock.calls[0][0] + expect(err).toBeInstanceOf(CargoValidationError) + expect(err.errors.map((e: CargoValidationError['errors'][number]) => e.message)).toContain('avatar is required') + }) + + it('binds multiple fields with distinct names (mixed @File/@Files, array form)', () => { + class MultiDTO { + @Files('a1') a1!: unknown[] + @File('a2') a2!: unknown + } + + const f1 = mockFile('a1', '1.png') + const f2 = mockFile('a1', '2.png') + const f3 = mockFile('a2', '3.png') + // upload.any(): req.files is a flat array mixing field names + const req = makeMockReq({ files: [f1, f2, f3] } as object) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(MultiDTO)(req, res, next) + + expect(next).toHaveBeenCalledWith() + const dto = getCargo(req) + expect(dto.a1).toEqual([f1, f2]) + expect(dto.a2).toBe(f3) + }) + + it('supports a custom locator for non-multer parsers', () => { + class CustomDTO { + @File() avatar!: unknown + } + + // express-fileupload shape: req.files = { avatar: } (not an array) + const efuFile = { name: 'x.png', mimetype: 'image/png', size: 1 } + const efuLocator: FileLocator = req => { + const out: Record = {} + const files = (req as unknown as { files?: Record }).files + if (files) { + for (const name of Object.keys(files)) out[name] = [files[name]] + } + return out + } + setCargoFileLocator(efuLocator) + + const req = makeMockReq({ files: { avatar: efuFile } } as object) + const res = makeMockRes() + const next = makeNext() + + bindingCargo(CustomDTO)(req, res, next) + + const dto = getCargo(req) + expect(dto.avatar).toBe(efuFile) + }) +}) From cda267c42c7f7e7cb57df12e13cacdafd59be1fd Mon Sep 17 00:00:00 2001 From: Jaehyun Yoon Date: Wed, 1 Jul 2026 22:39:35 +0900 Subject: [PATCH 2/4] feat: add multipart file upload example --- apps/example/package.json | 2 + apps/example/src/app.ts | 2 + apps/example/src/routers/file.ts | 67 ++++++++++++++++++++++++++++++++ pnpm-lock.yaml | 59 ++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+) create mode 100644 apps/example/src/routers/file.ts diff --git a/apps/example/package.json b/apps/example/package.json index c5043f9..51556ad 100644 --- a/apps/example/package.json +++ b/apps/example/package.json @@ -17,6 +17,7 @@ "express": "^4.18.2", "express-cargo": "workspace:^", "express-session": "^1.18.2", + "multer": "^2.2.0", "tslib": "^2.8.1" }, "devDependencies": { @@ -26,6 +27,7 @@ "@swc/helpers": "^0.5.13", "@types/express": "^4.17.23", "@types/express-session": "^1.18.2", + "@types/multer": "^2.1.0", "nodemon": "^3.1.7", "swc-node": "^1.0.0" } diff --git a/apps/example/src/app.ts b/apps/example/src/app.ts index 49b5e70..04c13d0 100644 --- a/apps/example/src/app.ts +++ b/apps/example/src/app.ts @@ -7,6 +7,7 @@ import decoratorRouter from './routers/decorator' import arrayFieldRouter from './routers/typeCasting' import errorHandlerRouter from './routers/errorHandler' import integrationRouter from './routers/integration' +import fileRouter from './routers/file' import './errors/cargoErrorHandler' const app = express() @@ -22,6 +23,7 @@ app.use(transformRouter) app.use(classFieldInheritanceRouter) app.use(decoratorRouter) app.use(arrayFieldRouter) +app.use(fileRouter) app.use(errorHandlerRouter) app.use(integrationRouter) diff --git a/apps/example/src/routers/file.ts b/apps/example/src/routers/file.ts new file mode 100644 index 0000000..2f3b7ab --- /dev/null +++ b/apps/example/src/routers/file.ts @@ -0,0 +1,67 @@ +import express, { Router } from 'express' +import multer from 'multer' +import { bindingCargo, getCargo, Body, File, Files } from 'express-cargo' + +const router: Router = express.Router() + +const upload = multer({ storage: multer.memoryStorage() }) + +function summarize(file?: Express.Multer.File) { + if (!file) return null + return { + fieldname: file.fieldname, + originalname: file.originalname, + mimetype: file.mimetype, + size: file.size, + } +} + +class UploadAvatarRequest { + @Body('username') + username!: string + + @File() + avatar!: Express.Multer.File +} + +router.post('/upload/avatar', upload.single('avatar'), bindingCargo(UploadAvatarRequest), (req, res) => { + const cargo = getCargo(req) + res.json({ username: cargo.username, avatar: summarize(cargo.avatar) }) +}) + +class UploadGalleryRequest { + @Files('photos') + photos!: Express.Multer.File[] +} + +router.post('/upload/gallery', upload.array('photos'), bindingCargo(UploadGalleryRequest), (req, res) => { + const cargo = getCargo(req) + res.json({ count: cargo.photos.length, photos: cargo.photos.map(summarize) }) +}) + +class UploadProfileRequest { + @Body('bio') + bio!: string + + @File('avatar') + avatar!: Express.Multer.File + + @Files('gallery') + gallery!: Express.Multer.File[] +} + +router.post( + '/upload/profile', + upload.fields([{ name: 'avatar', maxCount: 1 }, { name: 'gallery' }]), + bindingCargo(UploadProfileRequest), + (req, res) => { + const cargo = getCargo(req) + res.json({ + bio: cargo.bio, + avatar: summarize(cargo.avatar), + gallery: cargo.gallery.map(summarize), + }) + }, +) + +export default router diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c660016..bba4b25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: express-session: specifier: ^1.18.2 version: 1.18.2 + multer: + specifier: ^2.2.0 + version: 2.2.0 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -115,6 +118,9 @@ importers: '@types/express-session': specifier: ^1.18.2 version: 1.18.2 + '@types/multer': + specifier: ^2.1.0 + version: 2.1.0 nodemon: specifier: ^3.1.7 version: 3.1.10 @@ -2144,6 +2150,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@2.1.0': + resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==} + '@types/node-forge@1.3.11': resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} @@ -2562,6 +2571,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + append-field@1.0.0: + resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + arch@2.2.0: resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} @@ -2734,6 +2746,10 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} + bytes@3.0.0: resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} engines: {node: '>= 0.8'} @@ -2946,6 +2962,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -4851,6 +4871,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} + multicast-dns@7.2.5: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} hasBin: true @@ -6134,6 +6158,10 @@ packages: std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} + streamx@2.22.1: resolution: {integrity: sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==} @@ -6384,6 +6412,9 @@ packages: typedarray-to-buffer@3.1.5: resolution: {integrity: sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==} + typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + typescript@5.6.3: resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} engines: {node: '>=14.17'} @@ -9619,6 +9650,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@2.1.0': + dependencies: + '@types/express': 4.17.23 + '@types/node-forge@1.3.11': dependencies: '@types/node': 20.19.0 @@ -10094,6 +10129,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + append-field@1.0.0: {} + arch@2.2.0: {} arch@3.0.0: {} @@ -10330,6 +10367,10 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + busboy@1.6.0: + dependencies: + streamsearch: 1.1.0 + bytes@3.0.0: {} bytes@3.1.2: {} @@ -10539,6 +10580,13 @@ snapshots: concat-map@0.0.1: {} + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -13029,6 +13077,13 @@ snapshots: ms@2.1.3: {} + multer@2.2.0: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + type-is: 1.6.18 + multicast-dns@7.2.5: dependencies: dns-packet: 5.6.1 @@ -14449,6 +14504,8 @@ snapshots: std-env@3.9.0: {} + streamsearch@1.1.0: {} + streamx@2.22.1: dependencies: fast-fifo: 1.3.2 @@ -14687,6 +14744,8 @@ snapshots: dependencies: is-typedarray: 1.0.0 + typedarray@0.0.6: {} + typescript@5.6.3: {} typescript@5.8.3: {} From 433c1053fcb26415cb5e1893bd9d2a7ef119c4d9 Mon Sep 17 00:00:00 2001 From: Jaehyun Yoon Date: Wed, 1 Jul 2026 23:11:01 +0900 Subject: [PATCH 3/4] refactor: rename @File/@Files to @UploadedFile/@UploadedFiles --- apps/example/src/routers/file.ts | 10 +++---- packages/express-cargo/src/binding.ts | 6 ++--- packages/express-cargo/src/source.ts | 8 +++--- .../tests/binding/fileBinding.test.ts | 26 +++++++++---------- 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/apps/example/src/routers/file.ts b/apps/example/src/routers/file.ts index 2f3b7ab..c737a6b 100644 --- a/apps/example/src/routers/file.ts +++ b/apps/example/src/routers/file.ts @@ -1,6 +1,6 @@ import express, { Router } from 'express' import multer from 'multer' -import { bindingCargo, getCargo, Body, File, Files } from 'express-cargo' +import { bindingCargo, getCargo, Body, UploadedFile, UploadedFiles } from 'express-cargo' const router: Router = express.Router() @@ -20,7 +20,7 @@ class UploadAvatarRequest { @Body('username') username!: string - @File() + @UploadedFile() avatar!: Express.Multer.File } @@ -30,7 +30,7 @@ router.post('/upload/avatar', upload.single('avatar'), bindingCargo(UploadAvatar }) class UploadGalleryRequest { - @Files('photos') + @UploadedFiles('photos') photos!: Express.Multer.File[] } @@ -43,10 +43,10 @@ class UploadProfileRequest { @Body('bio') bio!: string - @File('avatar') + @UploadedFile('avatar') avatar!: Express.Multer.File - @Files('gallery') + @UploadedFiles('gallery') gallery!: Express.Multer.File[] } diff --git a/packages/express-cargo/src/binding.ts b/packages/express-cargo/src/binding.ts index e0d8823..c9eafb7 100644 --- a/packages/express-cargo/src/binding.ts +++ b/packages/express-cargo/src/binding.ts @@ -256,7 +256,7 @@ function bindSource({ metaClass, targetObject, sources, errors, sourceKey }: Bin const currentSource = meta.getSource() - // Uploaded files share one map; `@File` takes the first entry, `@Files` takes them all. + // Uploaded files share one map; `@UploadedFile` takes the first entry, `@UploadedFiles` takes them all. if (currentSource === 'file' || currentSource === 'files') { bindFile(meta, property, key, sources.file?.[key], currentSource === 'files', targetObject, errors, sourceKey) continue @@ -279,7 +279,7 @@ function bindSource({ metaClass, targetObject, sources, errors, sourceKey }: Bin } } -// Binds an uploaded file field. `@Files` receives the whole array; `@File` takes the first entry. +// Binds an uploaded file field. `@UploadedFiles` receives the whole array; `@UploadedFile` takes the first entry. function bindFile( meta: CargoFieldMetadata, property: string | symbol, @@ -356,7 +356,7 @@ export function bindingCargo(cargoClass: ClassConstructo return (req, res, next) => { try { const errors: CargoFieldError[] = [] - // Normalize uploaded files once; @File and @Files share the same map. + // Normalize uploaded files once; @UploadedFile and @UploadedFiles share the same map. const uploadedFiles = getCargoFileLocator()(req) const sources = { req: req, diff --git a/packages/express-cargo/src/source.ts b/packages/express-cargo/src/source.ts index b3ee56c..2094071 100644 --- a/packages/express-cargo/src/source.ts +++ b/packages/express-cargo/src/source.ts @@ -103,12 +103,12 @@ export const Session = createSourceDecorator('session') * @example * ```ts * class UploadExample { - * @File() + * @UploadedFile() * avatar: Express.Multer.File; * } * ``` */ -export const File = createSourceDecorator('file') +export const UploadedFile = createSourceDecorator('file') /** * Extracts all uploaded files sharing a field name as an array. @@ -116,9 +116,9 @@ export const File = createSourceDecorator('file') * @example * ```ts * class UploadExample { - * @Files('photos') + * @UploadedFiles('photos') * gallery: Express.Multer.File[]; * } * ``` */ -export const Files = createSourceDecorator('files') +export const UploadedFiles = createSourceDecorator('files') diff --git a/packages/express-cargo/tests/binding/fileBinding.test.ts b/packages/express-cargo/tests/binding/fileBinding.test.ts index 6f86798..e07cdd6 100644 --- a/packages/express-cargo/tests/binding/fileBinding.test.ts +++ b/packages/express-cargo/tests/binding/fileBinding.test.ts @@ -1,4 +1,4 @@ -import { bindingCargo, Body, File, Files, Optional, getCargo, CargoValidationError, setCargoFileLocator, getCargoFileLocator, FileLocator } from '../../src' +import { bindingCargo, Body, UploadedFile, UploadedFiles, Optional, getCargo, CargoValidationError, setCargoFileLocator, getCargoFileLocator, FileLocator } from '../../src' import { makeMockReq, makeMockRes, makeNext } from './testUtils' /** Builds a multer-like file object. */ @@ -17,7 +17,7 @@ describe('file source binding', () => { it('binds a single uploaded file from req.file (upload.single)', () => { class UploadDTO { @Body('title') title!: string - @File() avatar!: unknown + @UploadedFile() avatar!: unknown } const file = mockFile('avatar', 'me.png') @@ -34,9 +34,9 @@ describe('file source binding', () => { expect(dto.avatar).toBe(file) }) - it('binds every file with @Files (upload.array)', () => { + it('binds every file with @UploadedFiles (upload.array)', () => { class GalleryDTO { - @Files('photos') photos!: unknown[] + @UploadedFiles('photos') photos!: unknown[] } const f1 = mockFile('photos', '1.png') @@ -53,8 +53,8 @@ describe('file source binding', () => { it('binds files from the fielded object form (upload.fields)', () => { class ProfileDTO { - @File() avatar!: unknown - @Files('gallery') gallery!: unknown[] + @UploadedFile() avatar!: unknown + @UploadedFiles('gallery') gallery!: unknown[] } const avatar = mockFile('avatar', 'a.png') @@ -72,7 +72,7 @@ describe('file source binding', () => { it('preserves the parser-produced shape (no transformation, e.g. diskStorage path)', () => { class FileDTO { - @File() file!: unknown + @UploadedFile() file!: unknown } const diskFile = mockFile('file', 'x.png', { path: '/tmp/x.png', buffer: undefined }) @@ -89,7 +89,7 @@ describe('file source binding', () => { it('sets an optional file to null when it is missing', () => { class OptUploadDTO { - @File() @Optional() avatar?: unknown + @UploadedFile() @Optional() avatar?: unknown } const req = makeMockReq({}) @@ -105,7 +105,7 @@ describe('file source binding', () => { it('raises a required error when a mandatory file is missing', () => { class RequiredUploadDTO { - @File() avatar!: unknown + @UploadedFile() avatar!: unknown } const req = makeMockReq({}) @@ -119,10 +119,10 @@ describe('file source binding', () => { expect(err.errors.map((e: CargoValidationError['errors'][number]) => e.message)).toContain('avatar is required') }) - it('binds multiple fields with distinct names (mixed @File/@Files, array form)', () => { + it('binds multiple fields with distinct names (mixed @UploadedFile/@UploadedFiles, array form)', () => { class MultiDTO { - @Files('a1') a1!: unknown[] - @File('a2') a2!: unknown + @UploadedFiles('a1') a1!: unknown[] + @UploadedFile('a2') a2!: unknown } const f1 = mockFile('a1', '1.png') @@ -143,7 +143,7 @@ describe('file source binding', () => { it('supports a custom locator for non-multer parsers', () => { class CustomDTO { - @File() avatar!: unknown + @UploadedFile() avatar!: unknown } // express-fileupload shape: req.files = { avatar: } (not an array) From 2f9e89a5b328c23d75376ab1db3cd63eb5766515 Mon Sep 17 00:00:00 2001 From: Jaehyun Yoon Date: Wed, 1 Jul 2026 23:11:43 +0900 Subject: [PATCH 4/4] feat: reject @Transform on uploaded file fileds --- .../src/rules/transformerPriority.ts | 10 ++++-- .../tests/rules/transformerPriority.test.ts | 31 ++++++++++++++++++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/express-cargo/src/rules/transformerPriority.ts b/packages/express-cargo/src/rules/transformerPriority.ts index 5d879c9..d908c0e 100644 --- a/packages/express-cargo/src/rules/transformerPriority.ts +++ b/packages/express-cargo/src/rules/transformerPriority.ts @@ -7,5 +7,11 @@ const enumWithTransform: FieldRuleFn = s => { return hasEnum && hasTransform ? `@${Enum.name} cannot be combined with @Transform; @${Enum.name} installs its own transformer` : null } -/** Transformer-priority rules — decorators that own their transform reject an extra `@Transform`. */ -export const TRANSFORMER_PRIORITY_RULES: readonly FieldRuleFn[] = [enumWithTransform] +const fileWithTransform: FieldRuleFn = s => { + const isFile = s.sources.some(d => d.name === 'file' || d.name === 'files') + const hasTransform = s.appliedSelf.some(d => d.category === 'transform') + return isFile && hasTransform ? `@Transform cannot be applied to an uploaded file field; files are bound as-is from the parser` : null +} + +/** Transformer-priority rules — decorators whose semantics preclude a user `@Transform` reject it. */ +export const TRANSFORMER_PRIORITY_RULES: readonly FieldRuleFn[] = [enumWithTransform, fileWithTransform] diff --git a/packages/express-cargo/tests/rules/transformerPriority.test.ts b/packages/express-cargo/tests/rules/transformerPriority.test.ts index 5c89f06..f0f6b62 100644 --- a/packages/express-cargo/tests/rules/transformerPriority.test.ts +++ b/packages/express-cargo/tests/rules/transformerPriority.test.ts @@ -1,4 +1,4 @@ -import { Body, Enum, Transform } from '../../src' +import { Body, Enum, Transform, UploadedFile, UploadedFiles } from '../../src' import { expectViolation, validateCargoSchema } from './testUtils' enum Role { @@ -37,4 +37,33 @@ describe('schema validation — transformer-priority rules', () => { expect(() => validateCargoSchema(TransformOnlyDto)).not.toThrow() }) + + it('rejects @UploadedFile combined with @Transform', () => { + class FileAndTransformDto { + @UploadedFile() + @Transform((v: unknown) => v) + avatar!: unknown + } + + expectViolation(() => validateCargoSchema(FileAndTransformDto), 'avatar', '@Transform cannot be applied to an uploaded file field') + }) + + it('rejects @UploadedFiles combined with @Transform', () => { + class FilesAndTransformDto { + @UploadedFiles('photos') + @Transform((v: unknown) => v) + photos!: unknown[] + } + + expectViolation(() => validateCargoSchema(FilesAndTransformDto), 'photos', '@Transform cannot be applied to an uploaded file field') + }) + + it('accepts an uploaded file field without @Transform', () => { + class FileOnlyDto { + @UploadedFile() + avatar!: unknown + } + + expect(() => validateCargoSchema(FileOnlyDto)).not.toThrow() + }) })