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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -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"
}
Expand Down
2 changes: 2 additions & 0 deletions apps/example/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)

Expand Down
67 changes: 67 additions & 0 deletions apps/example/src/routers/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import express, { Router } from 'express'
import multer from 'multer'
import { bindingCargo, getCargo, Body, UploadedFile, UploadedFiles } 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

@UploadedFile()
avatar!: Express.Multer.File
}

router.post('/upload/avatar', upload.single('avatar'), bindingCargo(UploadAvatarRequest), (req, res) => {
const cargo = getCargo<UploadAvatarRequest>(req)
res.json({ username: cargo.username, avatar: summarize(cargo.avatar) })
})

class UploadGalleryRequest {
@UploadedFiles('photos')
photos!: Express.Multer.File[]
}

router.post('/upload/gallery', upload.array('photos'), bindingCargo(UploadGalleryRequest), (req, res) => {
const cargo = getCargo<UploadGalleryRequest>(req)
res.json({ count: cargo.photos.length, photos: cargo.photos.map(summarize) })
})

class UploadProfileRequest {
@Body('bio')
bio!: string

@UploadedFile('avatar')
avatar!: Express.Multer.File

@UploadedFiles('gallery')
gallery!: Express.Multer.File[]
}

router.post(
'/upload/profile',
upload.fields([{ name: 'avatar', maxCount: 1 }, { name: 'gallery' }]),
bindingCargo(UploadProfileRequest),
(req, res) => {
const cargo = getCargo<UploadProfileRequest>(req)
res.json({
bio: cargo.bio,
avatar: summarize(cargo.avatar),
gallery: cargo.gallery.map(summarize),
})
},
)

export default router
35 changes: 33 additions & 2 deletions packages/express-cargo/src/binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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; `@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
}
Comment thread
yuchem2 marked this conversation as resolved.

let value
const currentSourceData = sources[currentSource as keyof BindSources]
if (currentSourceData) {
value = currentSourceData[key]
}
Expand All @@ -272,6 +279,27 @@ function bindSource({ metaClass, targetObject, sources, errors, sourceKey }: Bin
}
}

// Binds an uploaded file field. `@UploadedFiles` receives the whole array; `@UploadedFile` 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)
}
Comment thread
yuchem2 marked this conversation as resolved.

function bindVirtual({ metaClass, targetObject, errors, sourceKey }: BindContext): void {
for (const property of metaClass.getVirtualFieldList()) {
const meta = metaClass.getFieldMetadata(property)
Expand Down Expand Up @@ -328,13 +356,16 @@ export function bindingCargo<T extends object = any>(cargoClass: ClassConstructo
return (req, res, next) => {
try {
const errors: CargoFieldError[] = []
// Normalize uploaded files once; @UploadedFile and @UploadedFiles share the same map.
const uploadedFiles = getCargoFileLocator()(req)
const sources = {
req: req,
body: req.body,
query: req.query,
params: req.params,
header: req.headers,
session: (req as any).session,
file: uploadedFiles,
}
const cargo = bindObject(cargoClass, result.rootMeta, sources, errors, result)

Expand Down
52 changes: 52 additions & 0 deletions packages/express-cargo/src/fileHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string, CargoFile[]>

/** Default locator for multer's `req.file` (single) and `req.files` (array or fielded object). */
const multerFileLocator: FileLocator = (req: Request) => {
const map: Record<string, CargoFile[]> = {}
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]
}
}
Comment thread
yuchem2 marked this conversation as resolved.

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
}
1 change: 1 addition & 0 deletions packages/express-cargo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
10 changes: 8 additions & 2 deletions packages/express-cargo/src/rules/transformerPriority.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
27 changes: 27 additions & 0 deletions packages/express-cargo/src/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
* @UploadedFile()
* avatar: Express.Multer.File;
* }
* ```
*/
export const UploadedFile = 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 {
* @UploadedFiles('photos')
* gallery: Express.Multer.File[];
* }
* ```
*/
export const UploadedFiles = createSourceDecorator('files')
3 changes: 2 additions & 1 deletion packages/express-cargo/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -201,6 +201,7 @@ export type BindSources = {
params: any
header: any
session: any
file: any
}

export type BindContext = {
Expand Down
Loading
Loading