File Upload Example
This guide demonstrates how to handle file uploads with @adi-family/http, including single file uploads, multiple files, progress tracking, and file validation.
Overview
We'll implement:
- Single file upload endpoint
- Multiple file uploads
- File size and type validation
- Progress tracking on client
- Image upload with metadata
- File download endpoints
1. Setup Dependencies
bash
npm install multer
npm install -D @types/multer2. File Upload Contracts
Since file uploads involve binary data that can't be easily validated with Zod, we'll use raw body handling:
typescript
// contracts/files.ts
import { route } from '@adi-family/http'
import { z } from 'zod'
import type { HandlerConfig } from '@adi-family/http'
// Upload single file
export const uploadFileConfig = {
method: 'POST',
route: route.static('/api/files/upload'),
// Note: Body schema omitted for multipart/form-data
response: {
schema: z.object({
id: z.string(),
filename: z.string(),
originalName: z.string(),
mimeType: z.string(),
size: z.number(),
url: z.string(),
uploadedAt: z.string().datetime()
})
}
} as const satisfies HandlerConfig
// Upload multiple files
export const uploadMultipleFilesConfig = {
method: 'POST',
route: route.static('/api/files/upload-multiple'),
response: {
schema: z.object({
files: z.array(z.object({
id: z.string(),
filename: z.string(),
originalName: z.string(),
mimeType: z.string(),
size: z.number(),
url: z.string()
}))
})
}
} as const satisfies HandlerConfig
// Upload image with metadata
export const uploadImageConfig = {
method: 'POST',
route: route.static('/api/images/upload'),
response: {
schema: z.object({
id: z.string(),
filename: z.string(),
url: z.string(),
thumbnailUrl: z.string(),
width: z.number(),
height: z.number(),
size: z.number()
})
}
} as const satisfies HandlerConfig
// Get file by ID
export const getFileConfig = {
method: 'GET',
route: route.dynamic('/api/files/:id', z.object({ id: z.string() })),
response: {
schema: z.object({
id: z.string(),
filename: z.string(),
originalName: z.string(),
mimeType: z.string(),
size: z.number(),
url: z.string(),
uploadedAt: z.string()
})
}
} as const satisfies HandlerConfig
// Download file
export const downloadFileConfig = {
method: 'GET',
route: route.dynamic('/api/files/:id/download', z.object({ id: z.string() }))
// Response is binary file, not JSON
} as const satisfies HandlerConfig
// Delete file
export const deleteFileConfig = {
method: 'DELETE',
route: route.dynamic('/api/files/:id', z.object({ id: z.string() })),
response: {
schema: z.object({
success: z.boolean()
})
}
} as const satisfies HandlerConfig3. File Storage Setup
typescript
// server/storage/files.ts
import multer from 'multer'
import path from 'path'
import fs from 'fs'
// Ensure upload directory exists
const uploadDir = path.join(process.cwd(), 'uploads')
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true })
}
// Configure multer storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir)
},
filename: (req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`
const ext = path.extname(file.originalname)
cb(null, `${uniqueSuffix}${ext}`)
}
})
// File filter for validation
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
// Allow only specific file types
const allowedMimes = [
'image/jpeg',
'image/png',
'image/gif',
'application/pdf',
'text/plain'
]
if (allowedMimes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error(`File type ${file.mimetype} not allowed`))
}
}
// Create multer instance
export const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB max
}
})
// In-memory database for file metadata
export const db = {
files: new Map<string, {
id: string
filename: string
originalName: string
mimeType: string
size: number
path: string
uploadedAt: string
}>()
}4. File Upload Handlers
Since file uploads require special middleware, we need to integrate with Express more directly:
typescript
// server/app.ts
import express from 'express'
import { serveExpress } from '@adi-family/http-express'
import { upload, db } from './storage/files'
import {
getFileHandler,
deleteFileHandler
} from './handlers/files'
const app = express()
app.use(express.json())
// Serve uploaded files statically
app.use('/uploads', express.static('uploads'))
// Single file upload endpoint
app.post('/api/files/upload', upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' })
}
const id = crypto.randomUUID()
const fileData = {
id,
filename: req.file.filename,
originalName: req.file.originalname,
mimeType: req.file.mimetype,
size: req.file.size,
path: req.file.path,
uploadedAt: new Date().toISOString()
}
db.files.set(id, fileData)
res.json({
id: fileData.id,
filename: fileData.filename,
originalName: fileData.originalName,
mimeType: fileData.mimeType,
size: fileData.size,
url: `/uploads/${fileData.filename}`,
uploadedAt: fileData.uploadedAt
})
})
// Multiple files upload endpoint
app.post('/api/files/upload-multiple', upload.array('files', 10), (req, res) => {
if (!req.files || !Array.isArray(req.files)) {
return res.status(400).json({ error: 'No files uploaded' })
}
const files = req.files.map(file => {
const id = crypto.randomUUID()
const fileData = {
id,
filename: file.filename,
originalName: file.originalname,
mimeType: file.mimetype,
size: file.size,
path: file.path,
uploadedAt: new Date().toISOString()
}
db.files.set(id, fileData)
return {
id: fileData.id,
filename: fileData.filename,
originalName: fileData.originalName,
mimeType: fileData.mimeType,
size: fileData.size,
url: `/uploads/${fileData.filename}`
}
})
res.json({ files })
})
// File download endpoint
app.get('/api/files/:id/download', (req, res) => {
const file = db.files.get(req.params.id)
if (!file) {
return res.status(404).json({ error: 'File not found' })
}
res.download(file.path, file.originalName)
})
// Register other handlers
serveExpress(app, [
getFileHandler,
deleteFileHandler
])
app.listen(3000, () => {
console.log('Server running on http://localhost:3000')
})5. Other File Handlers
typescript
// server/handlers/files.ts
import { handler } from '@adi-family/http'
import { getFileConfig, deleteFileConfig } from '../../contracts/files'
import { db } from '../storage/files'
import fs from 'fs'
// Get file metadata
export const getFileHandler = handler(getFileConfig, async (ctx) => {
const file = db.files.get(ctx.params.id)
if (!file) {
throw new Error('File not found')
}
return {
id: file.id,
filename: file.filename,
originalName: file.originalName,
mimeType: file.mimeType,
size: file.size,
url: `/uploads/${file.filename}`,
uploadedAt: file.uploadedAt
}
})
// Delete file
export const deleteFileHandler = handler(deleteFileConfig, async (ctx) => {
const file = db.files.get(ctx.params.id)
if (!file) {
throw new Error('File not found')
}
// Delete physical file
if (fs.existsSync(file.path)) {
fs.unlinkSync(file.path)
}
// Delete metadata
db.files.delete(ctx.params.id)
return { success: true }
})6. Client Usage
Basic Upload
typescript
// client/file-upload.ts
async function uploadFile(file: File) {
const formData = new FormData()
formData.append('file', file)
const response = await fetch('http://localhost:3000/api/files/upload', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error('Upload failed')
}
const result = await response.json()
console.log('Uploaded:', result)
return result
}
// Usage
const fileInput = document.querySelector<HTMLInputElement>('#file-input')!
fileInput.addEventListener('change', async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) {
const result = await uploadFile(file)
console.log('File URL:', result.url)
}
})Upload with Progress
typescript
async function uploadFileWithProgress(
file: File,
onProgress: (progress: number) => void
) {
const formData = new FormData()
formData.append('file', file)
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
// Track upload progress
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100
onProgress(progress)
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText))
} else {
reject(new Error('Upload failed'))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'http://localhost:3000/api/files/upload')
xhr.send(formData)
})
}
// Usage
const result = await uploadFileWithProgress(file, (progress) => {
console.log(`Upload progress: ${progress.toFixed(2)}%`)
// Update UI progress bar
progressBar.style.width = `${progress}%`
})Multiple Files Upload
typescript
async function uploadMultipleFiles(files: File[]) {
const formData = new FormData()
files.forEach(file => {
formData.append('files', file)
})
const response = await fetch('http://localhost:3000/api/files/upload-multiple', {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error('Upload failed')
}
return response.json()
}
// Usage
const files = Array.from(fileInput.files || [])
const result = await uploadMultipleFiles(files)
console.log(`Uploaded ${result.files.length} files`)File Download
typescript
import { BaseClient } from '@adi-family/http'
import { getFileConfig } from '../contracts/files'
const client = new BaseClient({
baseUrl: 'http://localhost:3000'
})
async function downloadFile(fileId: string) {
// Get file metadata
const file = await client.run(getFileConfig, {
params: { id: fileId }
})
// Download file
const response = await fetch(`http://localhost:3000/api/files/${fileId}/download`)
const blob = await response.blob()
// Create download link
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = file.originalName
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}7. React Component Example
typescript
// components/FileUpload.tsx
import { useState } from 'react'
export function FileUpload() {
const [file, setFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [result, setResult] = useState<any>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0]
if (selectedFile) {
setFile(selectedFile)
setResult(null)
}
}
const handleUpload = async () => {
if (!file) return
setUploading(true)
setProgress(0)
try {
const uploaded = await uploadFileWithProgress(file, setProgress)
setResult(uploaded)
} catch (error) {
console.error('Upload failed:', error)
} finally {
setUploading(false)
}
}
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
/>
{file && (
<div>
<p>Selected: {file.name} ({(file.size / 1024).toFixed(2)} KB)</p>
<button onClick={handleUpload} disabled={uploading}>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
)}
{uploading && (
<div>
<div style={{ width: '100%', backgroundColor: '#eee' }}>
<div
style={{
width: `${progress}%`,
height: '20px',
backgroundColor: '#4caf50'
}}
/>
</div>
<p>{progress.toFixed(2)}%</p>
</div>
)}
{result && (
<div>
<p>Upload successful!</p>
<img src={`http://localhost:3000${result.url}`} alt={result.originalName} />
<a href={`http://localhost:3000${result.url}`} download>
Download
</a>
</div>
)}
</div>
)
}8. File Validation
Server-Side Validation
typescript
// server/storage/files.ts
const imageFilter: multer.Options['fileFilter'] = (req, file, cb) => {
const allowedMimes = ['image/jpeg', 'image/png', 'image/gif']
if (!allowedMimes.includes(file.mimetype)) {
cb(new Error('Only images are allowed'))
return
}
cb(null, true)
}
export const imageUpload = multer({
storage,
fileFilter: imageFilter,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB for images
}
})Client-Side Validation
typescript
function validateFile(file: File): string | null {
const maxSize = 10 * 1024 * 1024 // 10MB
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'application/pdf']
if (file.size > maxSize) {
return `File too large. Max size: ${maxSize / 1024 / 1024}MB`
}
if (!allowedTypes.includes(file.type)) {
return `File type ${file.type} not allowed`
}
return null
}
// Usage
const error = validateFile(file)
if (error) {
alert(error)
return
}Best Practices
1. Validate File Size and Type
Always validate on both client and server.
2. Use Unique Filenames
Prevent filename collisions:
typescript
const uniqueFilename = `${Date.now()}-${crypto.randomUUID()}${ext}`3. Limit Upload Size
Configure appropriate limits:
typescript
limits: {
fileSize: 10 * 1024 * 1024, // 10MB
files: 5 // Max 5 files
}4. Clean Up Failed Uploads
Remove temporary files if upload fails.
5. Use Cloud Storage in Production
For production, use S3, Google Cloud Storage, etc., instead of local disk.
Next Steps
- Basic Examples - CRUD operations
- Authentication - Secure file uploads
- Advanced Examples - Complex patterns