@upload ๋ฐ์ฝ๋ ์ดํฐ๋ ํ์ผ ์
๋ก๋๋ฅผ ์ฒ๋ฆฌํ๋ API๋ฅผ ์์ฑํฉ๋๋ค. Multipart form-data ์์ฒญ์ ์๋์ผ๋ก ํ์ฑํ๊ณ , ํ์ผ ๊ฐ์ฒด๋ฅผ ์ ๊ณตํฉ๋๋ค.
๊ธฐ๋ณธ ์ฌ์ฉ๋ฒ
๋ณต์ฌ
import { BaseModelClass, api, upload } from "sonamu";
class FileModelClass extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadAvatar() {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error("No file uploaded");
}
// ํ์ผ ์ ์ฅ
const key = `avatars/${Date.now()}_${file.filename}`;
const url = await file.saveToDisk(key);
return { url, filename: file.filename, size: file.size };
}
}
์ต์
mode
ํ์ผ ์ ๋ก๋ ๋ชจ๋๋ฅผ ์ง์ ํฉ๋๋ค.๋ณต์ฌ
type UploadMode = "single" | "multiple";
"single"
- single (๋จ์ผ ํ์ผ)
- multiple (์ฌ๋ฌ ํ์ผ)
๋ณต์ฌ
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadAvatar() {
const { file } = Sonamu.getUploadContext();
// file: UploadedFile | undefined
if (!file) {
throw new Error("No file");
}
return {
filename: file.filename,
size: file.size
};
}
๋ณต์ฌ
@api({ httpMethod: "POST" })
@upload({ mode: "multiple" })
async uploadDocuments() {
const { files } = Sonamu.getUploadContext();
// files: UploadedFile[]
if (files.length === 0) {
throw new Error("No files");
}
const results = [];
for (const file of files) {
const key = `documents/${Date.now()}_${file.filename}`;
const url = await file.saveToDisk(key);
results.push({
filename: file.filename,
url
});
}
return results;
}
UploadContext ์ฌ์ฉ๋ฒ
Sonamu.getUploadContext()๋ก ์
๋ก๋๋ ํ์ผ์ ์ ๊ทผํฉ๋๋ค.
๋ณต์ฌ
interface UploadContext {
file: UploadedFile | undefined; // single ๋ชจ๋
files: UploadedFile[]; // multiple ๋ชจ๋
}
UploadedFile ๊ฐ์ฒด
๋ณต์ฌ
class UploadedFile {
// ์๋ณธ ํ์ผ ์ด๋ฆ
get filename(): string;
// MIME ํ์
get mimetype(): string;
// ํ์ผ ํฌ๊ธฐ (bytes)
get size(): number;
// ํ์ฅ์ (์ ์ ์ธ, ์: "jpg", "png")
get extname(): string | false;
// saveToDisk ํ ์ ์ฅ๋ URL (Unsigned)
get url(): string | undefined;
// saveToDisk ํ ์ ์ฅ๋ Signed URL
get signedUrl(): string | undefined;
// ์๋ณธ Fastify MultipartFile ์ ๊ทผ
get raw(): MultipartFile;
// ํ์ผ์ Buffer๋ก ์ฝ๊ธฐ
async toBuffer(): Promise<Buffer>;
// MD5 ํด์ ๊ณ์ฐ
async md5(): Promise<string>;
// ํ์ผ์ ๋์คํฌ์ ์ ์ฅ (URL ๋ฐํ)
async saveToDisk(key: string, diskName?: DriverKey): Promise<string>;
}
ํ์ผ ์ ๋ณด ํ์ธ
๋ณต์ฌ
@upload({ mode: "single" })
async uploadFile() {
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error("No file");
}
console.log("Filename:", file.filename);
console.log("MIME type:", file.mimetype);
console.log("Size:", file.size);
console.log("Extension:", file.extname);
return { uploaded: true };
}
ํ์ผ ์ฝ๊ธฐ
๋ณต์ฌ
@upload({ mode: "single" })
async processImage() {
const { file } = Sonamu.getUploadContext();
// Buffer๋ก ์ฝ๊ธฐ
const buffer = await file.toBuffer();
// ์ด๋ฏธ์ง ์ฒ๋ฆฌ
const processed = await sharp(buffer)
.resize(300, 300)
.toBuffer();
return { size: processed.length };
}
ํ์ผ ์ ์ฅ
๋ณต์ฌ
@upload({ mode: "single" })
async uploadDocument() {
const { file } = Sonamu.getUploadContext();
// ๋์คํฌ์ ์ ์ฅ
const key = `uploads/${Date.now()}_${file.filename}`;
const url = await file.saveToDisk(key);
// ์ ์ฅ๋ URL ์ ๊ทผ
console.log("URL:", file.url);
console.log("Signed URL:", file.signedUrl);
return { url, filename: file.filename, size: file.size };
}
MD5 ํด์ ๊ณ์ฐ
๋ณต์ฌ
@upload({ mode: "single" })
async uploadWithHash() {
const { file } = Sonamu.getUploadContext();
// MD5 ํด์ ๊ณ์ฐ
const hash = await file.md5();
// ํด์๋ฅผ ํ์ผ๋ช
์ ํฌํจ
const key = `uploads/${hash}${file.extname ? `.${file.extname}` : ''}`;
const url = await file.saveToDisk(key);
return { url, hash };
}
์คํ ๋ฆฌ์ง ๋๋ผ์ด๋ฒ ์ฌ์ฉ
Sonamu๋ ์ฌ๋ฌ ์คํ ๋ฆฌ์ง ๋๋ผ์ด๋ฒ๋ฅผ ์ ๊ณตํฉ๋๋ค.๋ณต์ฌ
@upload({ mode: "single" })
async uploadToS3() {
const { file } = Sonamu.getUploadContext();
// S3 ๋์คํฌ์ ์ ์ฅ (sonamu.config.ts์์ ์ค์ )
const key = `avatars/${Date.now()}_${file.filename}`;
const url = await file.saveToDisk(key, "s3");
return { url };
}
์คํ ๋ฆฌ์ง ๋๋ผ์ด๋ฒ๋
sonamu.config.ts์์ ์ค์ ํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค.๋ค๋ฅธ ๋ฐ์ฝ๋ ์ดํฐ์ ํจ๊ป ์ฌ์ฉ
@api์ ํจ๊ป
๋ณต์ฌ
@api({
httpMethod: "POST",
clients: ["axios-multipart"]
})
@upload({ mode: "single" })
async uploadFile() {
// ํ์ผ ์
๋ก๋ API
}
@upload์ ์ฌ์ฉํ๋ฉด clients ์ต์
์ด ์๋์ผ๋ก ์ค์ ๋ฉ๋๋ค.@transactional๊ณผ ํจ๊ป
๋ณต์ฌ
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
@transactional()
async uploadAndSave() {
const { file } = Sonamu.getUploadContext();
const wdb = this.getDB("w");
// ํ์ผ ์ ์ฅ + DB ์
๋ฐ์ดํธ๋ฅผ ํธ๋์ญ์
์ผ๋ก
const key = `documents/${Date.now()}_${file.filename}`;
const url = await file.saveToDisk(key);
await wdb.table("documents").insert({
filename: file.filename,
url,
size: file.size,
created_at: new Date()
});
return { url };
}
ํด๋ผ์ด์ธํธ ์ฌ์ฉ (Web)
Sonamu๋ ์๋์ผ๋ก ํ์ผ ์ ๋ก๋ ํด๋ผ์ด์ธํธ ์ฝ๋๋ฅผ ์์ฑํฉ๋๋ค.Axios (๋จ์ผ ํ์ผ)
๋ณต์ฌ
import { FileService } from "@/services/FileService";
const formData = new FormData();
formData.append("file", file);
const result = await FileService.uploadAvatar(formData);
Axios (์ฌ๋ฌ ํ์ผ)
๋ณต์ฌ
const formData = new FormData();
files.forEach(file => {
formData.append("files", file);
});
const result = await FileService.uploadDocuments(formData);
React ์์
๋ณต์ฌ
import { useState } from "react";
import { FileService } from "@/services/FileService";
function FileUploader() {
const [uploading, setUploading] = useState(false);
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const result = await FileService.uploadAvatar(formData);
console.log("Uploaded:", result.url);
} catch (error) {
console.error("Upload failed:", error);
} finally {
setUploading(false);
}
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={uploading}
/>
{uploading && <p>Uploading...</p>}
</div>
);
}
TanStack Query ์์
๋ณต์ฌ
import { useMutation } from "@tanstack/react-query";
import { FileService } from "@/services/FileService";
function useUploadFile() {
return useMutation({
mutationFn: (file: File) => {
const formData = new FormData();
formData.append("file", file);
return FileService.uploadAvatar(formData);
},
onSuccess: (data) => {
console.log("Upload success:", data);
},
onError: (error) => {
console.error("Upload failed:", error);
}
});
}
function FileUploader() {
const upload = useUploadFile();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
upload.mutate(file);
}
};
return (
<div>
<input
type="file"
onChange={handleFileChange}
disabled={upload.isPending}
/>
{upload.isPending && <p>Uploading...</p>}
{upload.isSuccess && <p>Success: {upload.data.url}</p>}
{upload.isError && <p>Error: {upload.error.message}</p>}
</div>
);
}
ํ์ผ ๊ฒ์ฆ
MIME ํ์ ๊ฒ์ฆ
๋ณต์ฌ
@upload({ mode: "single" })
async uploadImage() {
const { file } = Sonamu.getUploadContext();
const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
if (!allowedTypes.includes(file.mimetype)) {
throw new Error(`Invalid file type: ${file.mimetype}`);
}
return await this.processImage(file);
}
ํ์ผ ํฌ๊ธฐ ๊ฒ์ฆ
๋ณต์ฌ
@upload({ mode: "single" })
async uploadDocument() {
const { file } = Sonamu.getUploadContext();
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
throw new Error("File too large");
}
return await this.saveDocument(file);
}
ํ์ผ ํ์ฅ์ ๊ฒ์ฆ
๋ณต์ฌ
@upload({ mode: "single" })
async uploadFile() {
const { file } = Sonamu.getUploadContext();
const allowedExtensions = ["jpg", "png", "pdf"];
if (!file.extname || !allowedExtensions.includes(file.extname)) {
throw new Error(`Invalid file extension: ${file.extname}`);
}
return await this.saveFile(file);
}
์ด๋ฏธ์ง ์ฒ๋ฆฌ
Sharp ์ฌ์ฉ
๋ณต์ฌ
import sharp from "sharp";
@upload({ mode: "single" })
async uploadAndResizeImage() {
const { file } = Sonamu.getUploadContext();
const buffer = await file.toBuffer();
// ์ด๋ฏธ์ง ๋ฆฌ์ฌ์ด์ง
const resized = await sharp(buffer)
.resize(800, 600, { fit: "inside" })
.jpeg({ quality: 80 })
.toBuffer();
// ์ธ๋ค์ผ ์์ฑ
const thumbnail = await sharp(buffer)
.resize(200, 200, { fit: "cover" })
.jpeg({ quality: 70 })
.toBuffer();
// S3์ ์
๋ก๋ (Buffer๋ฅผ ์ง์ ์ ์ฅ)
const imageKey = `images/${Date.now()}.jpg`;
await Sonamu.storage.use("s3").put(imageKey, resized);
const imageUrl = await Sonamu.storage.use("s3").getUrl(imageKey);
const thumbKey = `thumbnails/${Date.now()}.jpg`;
await Sonamu.storage.use("s3").put(thumbKey, thumbnail);
const thumbUrl = await Sonamu.storage.use("s3").getUrl(thumbKey);
return { imageUrl, thumbUrl };
}
์ ์ฝ์ฌํญ
1. @api์ ํจ๊ป ์ฌ์ฉ ํ์
@upload๋ @api ์์ด๋ ๋์ํ์ง๋ง, ํจ๊ป ์ฌ์ฉํ๋ ๊ฒ์ ๊ถ์ฅํฉ๋๋ค:
๋ณต์ฌ
// โ
๊ถ์ฅ
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
async uploadFile() {}
// โ ๏ธ ๋์ํ์ง๋ง ๊ถ์ฅํ์ง ์์
@upload({ mode: "single" })
async uploadFile() {}
2. httpMethod๋ POST
@upload๋ฅผ ์ฌ์ฉํ๋ฉด ์๋์ผ๋ก httpMethod: "POST"๊ฐ ์ค์ ๋ฉ๋๋ค.
๋ณต์ฌ
@api({ httpMethod: "GET" }) // ๋ฌด์๋จ
@upload()
async uploadFile() {
// POST๋ก ๋์ํจ
}
3. clients ์๋ ์ค์
@upload๋ฅผ ์ฌ์ฉํ๋ฉด clients ์ต์
์ด ์๋์ผ๋ก ์ค์ ๋ฉ๋๋ค:
๋ณต์ฌ
// mode: "single"
@upload({ mode: "single" })
async uploadFile() {
// clients: ["axios-multipart", "tanstack-mutation-multipart"]
}
// mode: "multiple"
@upload({ mode: "multiple" })
async uploadFiles() {
// clients: ["axios-multipart", "tanstack-mutation-multipart"]
}
๋ก๊น
@upload ๋ฐ์ฝ๋ ์ดํฐ๋ ์๋์ผ๋ก ๋ก๊ทธ๋ฅผ ๋จ๊น๋๋ค:
๋ณต์ฌ
@upload({ mode: "single" })
async uploadFile() {
// ์๋ ๋ก๊ทธ:
// [DEBUG] upload: FileModel.uploadFile
}
์์ ๋ชจ์
- ํ๋กํ ์ด๋ฏธ์ง
- ์ฌ๋ฌ ํ์ผ ์ ๋ก๋
- CSV ํ์ผ ์ฒ๋ฆฌ
- MD5 ํด์ ๊ธฐ๋ฐ ์ ์ฅ
๋ณต์ฌ
class UserModelClass extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
@transactional()
async uploadAvatar() {
const { user } = Sonamu.getContext();
const { file } = Sonamu.getUploadContext();
if (!file) {
throw new Error("No file uploaded");
}
// ์ด๋ฏธ์ง ํ์
๊ฒ์ฆ
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.mimetype)) {
throw new Error("Invalid image type");
}
// ์ด๋ฏธ์ง ์ฒ๋ฆฌ
const buffer = await file.toBuffer();
const processed = await sharp(buffer)
.resize(300, 300, { fit: "cover" })
.jpeg({ quality: 85 })
.toBuffer();
// S3 ์
๋ก๋
const key = `avatars/${user.id}/${Date.now()}.jpg`;
await Sonamu.storage.use("s3").put(key, processed);
const url = await Sonamu.storage.use("s3").getUrl(key);
// DB ์
๋ฐ์ดํธ
const wdb = this.getDB("w");
await wdb.table("users")
.where("id", user.id)
.update({ avatar_url: url });
return { url };
}
}
๋ณต์ฌ
class DocumentModelClass extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "multiple" })
@transactional()
async uploadDocuments(projectId: number) {
const { files } = Sonamu.getUploadContext();
if (files.length === 0) {
throw new Error("No files uploaded");
}
const wdb = this.getDB("w");
const results = [];
for (const file of files) {
// ํ์ผ ํ์
๊ฒ์ฆ
const allowedTypes = [
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
];
if (!allowedTypes.includes(file.mimetype)) {
throw new Error(`Invalid file type: ${file.filename}`);
}
// ํ์ผ ์ ์ฅ
const key = `documents/${projectId}/${Date.now()}_${file.filename}`;
const url = await file.saveToDisk(key);
// DB์ ๊ธฐ๋ก
const doc = await wdb.table("documents").insert({
project_id: projectId,
filename: file.filename,
url,
size: file.size,
mimetype: file.mimetype,
created_at: new Date()
}).returning("*");
results.push(doc[0]);
}
return results;
}
}
๋ณต์ฌ
import Papa from "papaparse";
class ImportModelClass extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "single" })
@transactional()
async importUsers() {
const { file } = Sonamu.getUploadContext();
if (file.mimetype !== "text/csv") {
throw new Error("CSV file required");
}
// CSV ํ์ฑ
const buffer = await file.toBuffer();
const text = buffer.toString("utf-8");
const parsed = Papa.parse<UserImportRow>(text, {
header: true,
skipEmptyLines: true
});
if (parsed.errors.length > 0) {
throw new Error("CSV parsing failed");
}
// ๋ฐ์ดํฐ ๊ฒ์ฆ ๋ฐ ์ ์ฅ
const wdb = this.getDB("w");
const results = [];
for (const row of parsed.data) {
// ๊ฒ์ฆ
if (!row.email || !row.name) {
throw new Error(`Invalid row: ${JSON.stringify(row)}`);
}
// ์ ์ฅ
const user = await wdb.table("users")
.insert({
email: row.email,
name: row.name,
phone: row.phone || null,
created_at: new Date()
})
.returning("*");
results.push(user[0]);
}
return {
imported: results.length,
users: results
};
}
}
๋ณต์ฌ
class FileModelClass extends BaseModelClass {
@api({ httpMethod: "POST" })
@upload({ mode: "multiple" })
async uploadWithDeduplication() {
const { files } = Sonamu.getUploadContext();
const results = [];
for (const file of files) {
// MD5 ํด์ ๊ณ์ฐ
const hash = await file.md5();
// ํด์๋ก ํ์ผ ์ ์ฅ (์ค๋ณต ๋ฐฉ์ง)
const ext = file.extname || "bin";
const key = `uploads/${hash}.${ext}`;
const url = await file.saveToDisk(key);
results.push({
filename: file.filename,
hash,
url,
size: file.size
});
}
return results;
}
}