diff --git a/apps/admin/src/constants/api.ts b/apps/admin/src/constants/api.ts
index c2c29155..2ba21251 100644
--- a/apps/admin/src/constants/api.ts
+++ b/apps/admin/src/constants/api.ts
@@ -4,5 +4,5 @@ export const APIRoute = {
githubTags: '/github/search/repositories?q=stars:>500&sort=stars&order=desc',
devToTags: 'https://dev.to/api/tags',
project: `${api}/project`,
- image: `${api}/image`,
+ file: `${api}/file`,
} as const;
diff --git a/apps/admin/src/pages/project/_components/card.tsx b/apps/admin/src/pages/project/_components/card.tsx
index aaab7b31..18b3b98a 100644
--- a/apps/admin/src/pages/project/_components/card.tsx
+++ b/apps/admin/src/pages/project/_components/card.tsx
@@ -16,13 +16,15 @@ export function Card({ project }: CardProps) {
return (
-

setImageLoaded(true)}
- className='absolute object-center object-cover size-full'
- />
+ {project.previews[0]?.url && (
+

setImageLoaded(true)}
+ className='absolute object-center object-cover size-full'
+ />
+ )}
diff --git a/apps/admin/src/services/image.ts b/apps/admin/src/services/file.ts
similarity index 53%
rename from apps/admin/src/services/image.ts
rename to apps/admin/src/services/file.ts
index e418e109..5b252140 100644
--- a/apps/admin/src/services/image.ts
+++ b/apps/admin/src/services/file.ts
@@ -1,7 +1,17 @@
import { APIRoute } from '@/constants/api';
-import { mapImageFile } from '@/types/image';
+import { mapFileUpload } from '@/types/file';
-export const ImageService = {
+/**
+ * FileService provides methods for uploading files to the backend via UploadThing.
+ */
+export const FileService = {
+ /**
+ * Uploads a file by first requesting an upload URL from the backend, then uploading the file to storage.
+ *
+ * @param file - The File object to upload
+ * @param signal - Optional AbortSignal to cancel the upload request
+ * @returns The URL of the uploaded file, or null if the upload fails
+ */
upload: async ({
file,
signal,
@@ -10,7 +20,7 @@ export const ImageService = {
signal?: AbortSignal;
}): Promise => {
try {
- const response = await fetch(`${APIRoute.image}/upload`, {
+ const response = await fetch(`${APIRoute.file}/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@@ -29,21 +39,21 @@ export const ImageService = {
if (!response.ok) {
throw new Error(
- `failed to upload project image (status: ${response.status} - ${response.statusText})`,
+ `failed to upload file (status: ${response.status} - ${response.statusText})`,
);
}
const data = await response.json();
- const imageFile = mapImageFile(data.file);
+ const fileUpload = mapFileUpload(data.file);
const formData = new FormData();
- Object.entries(imageFile.fields).forEach(([k, v]) => {
+ Object.entries(fileUpload.fields).forEach(([k, v]) => {
formData.append(k, v);
});
formData.append('file', file, file.name);
- const uploadResponse = await fetch(imageFile.URL, {
+ const uploadResponse = await fetch(fileUpload.URL, {
method: 'POST',
body: formData,
signal,
@@ -55,9 +65,9 @@ export const ImageService = {
);
}
- return imageFile.fileURL;
+ return fileUpload.fileURL;
} catch (error) {
- console.error('ImageService.upload error: ', error);
+ console.error('FileService.upload error: ', error);
return null;
}
},
diff --git a/apps/admin/src/types/file.ts b/apps/admin/src/types/file.ts
new file mode 100644
index 00000000..9c744081
--- /dev/null
+++ b/apps/admin/src/types/file.ts
@@ -0,0 +1,169 @@
+import { ensureDate, ensureNumber, ensureString } from '.';
+
+export const FileRole = {
+ image: 'image',
+} as const;
+
+export type File = {
+ id: string;
+ parentTable: string;
+ parentID: string;
+ role: (typeof FileRole)[keyof typeof FileRole];
+ name: string;
+ url: string;
+ type: string;
+ size: number;
+ createdAt: Date;
+ updatedAt: Date;
+};
+
+/**
+ * Validates that a value is a valid file role.
+ *
+ * @param value - The value to validate as a file role
+ * @returns The validated file role value
+ * @throws {Error} If the value is not a string or is not a valid file role
+ */
+function ensureRole(value: unknown): 'image' {
+ const roleString = ensureString({
+ value,
+ name: 'role',
+ });
+
+ if (
+ !Object.values(FileRole).includes(
+ roleString as (typeof FileRole)[keyof typeof FileRole],
+ )
+ ) {
+ throw new Error(`Invalid file role: ${roleString}`);
+ }
+
+ return roleString as (typeof FileRole)[keyof typeof FileRole];
+}
+
+/**
+ * Maps a DTO object to a File domain object.
+ *
+ * @param dto - The data transfer object to map
+ * @returns The mapped File domain object with validated properties
+ * @throws {Error} If the DTO is not a valid object or contains invalid properties
+ */
+export function mapFile(dto: unknown): File {
+ if (typeof dto !== 'object' || dto === null) {
+ throw new Error('Invalid file DTO');
+ }
+
+ const d = dto as Record;
+
+ return {
+ id: ensureString({ value: d.id, name: 'id' }),
+ parentTable: ensureString({ value: d.parent_table, name: 'parent_table' }),
+ parentID: ensureString({ value: d.parent_id, name: 'parent_id' }),
+ role: ensureRole(d.role),
+ name: ensureString({ value: d.name, name: 'name' }),
+ url: ensureString({ value: d.url, name: 'url' }),
+ type: ensureString({ value: d.type, name: 'type' }),
+ size: ensureNumber({ value: d.size, name: 'size' }),
+ createdAt: ensureDate({ value: d.created_at, name: 'created_at' }),
+ updatedAt: ensureDate({ value: d.updated_at, name: 'updated_at' }),
+ };
+}
+
+/**
+ * Converts a File domain object to a JSON-serializable format.
+ *
+ * @param file - The File domain object to convert
+ * @returns A record with snake_case keys suitable for API serialization, excluding undefined values
+ */
+export function toJSONFile(file: Partial): Record {
+ const result: Record = {
+ id: file.id,
+ parent_table: file.parentTable,
+ parent_id: file.parentID,
+ role: file.role,
+ name: file.name,
+ url: file.url,
+ type: file.type,
+ size: file.size,
+ created_at: file.createdAt ? file.createdAt.toISOString() : undefined,
+ updated_at: file.updatedAt ? file.updatedAt.toISOString() : undefined,
+ };
+
+ // Filter out undefined values
+ return Object.fromEntries(
+ Object.entries(result).filter(([, v]) => v !== undefined),
+ );
+}
+
+// -------------------- UPLOADTHING types below --------------------
+
+/**
+ * Represents a file that has been successfully uploaded via UploadThing.
+ */
+export type FileUpload = {
+ key: string;
+ fileName: string;
+ fileType: string;
+ fileURL: string;
+ contentDisposition: string;
+ pollingJWT: string;
+ pollingURL: string;
+ customId?: string;
+ URL: string;
+ fields: Record;
+};
+
+/**
+ * Validates and converts an unknown value to a fields object with string values.
+ *
+ * @param value - The value to validate and convert as fields
+ * @returns An object with string values
+ * @throws {Error} If any field value is not a string
+ * @throws {Error} If the value is not a valid object
+ */
+function ensureFields(value: unknown): { [k: string]: string } {
+ if (!value || typeof value !== 'object') {
+ throw new Error("Expected property 'fields' to be an object");
+ }
+
+ return Object.fromEntries(
+ Object.entries(value).map(([k, v]) => [
+ k,
+ ensureString({ value: v, name: k }),
+ ]),
+ );
+}
+
+/**
+ * Maps a DTO object to a FileUpload domain object.
+ *
+ * @param dto - The data transfer object to map
+ * @returns The mapped FileUpload domain object with validated properties
+ * @throws {Error} If the DTO is not a valid object or contains invalid properties
+ */
+export function mapFileUpload(dto: unknown): FileUpload {
+ if (typeof dto !== 'object' || dto === null) {
+ throw new Error('Invalid file DTO');
+ }
+
+ const d = dto as Record;
+
+ return {
+ key: ensureString({ value: d.key, name: 'key' }),
+ fileName: ensureString({ value: d.file_name, name: 'file_name' }),
+ fileType: ensureString({ value: d.file_type, name: 'file_type' }),
+ fileURL: ensureString({ value: d.file_url, name: 'file_url' }),
+ contentDisposition: ensureString({
+ value: d.content_disposition,
+ name: 'content_disposition',
+ }),
+ pollingJWT: ensureString({ value: d.polling_jwt, name: 'polling_jwt' }),
+ pollingURL: ensureString({ value: d.polling_url, name: 'polling_url' }),
+ customId:
+ d.custom_id != null
+ ? ensureString({ value: d.custom_id, name: 'custom_id' })
+ : undefined,
+ URL: ensureString({ value: d.url, name: 'url' }),
+ fields: ensureFields(d.fields),
+ };
+}
diff --git a/apps/admin/src/types/image.ts b/apps/admin/src/types/image.ts
deleted file mode 100644
index 95c30fe0..00000000
--- a/apps/admin/src/types/image.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { ensureString } from '.';
-
-export type ImageFile = {
- key: string;
- fileName: string;
- fileType: string;
- fileURL: string;
- contentDisposition: string;
- pollingJWT: string;
- pollingURL: string;
- customId?: string;
- URL: string;
- fields: Record;
-};
-
-export function mapImageFile(dto: unknown): ImageFile {
- if (typeof dto !== 'object' || dto === null) {
- throw new Error('Invalid image DTO');
- }
-
- const d = dto as Record;
-
- const fields =
- d.fields && typeof d.fields === 'object'
- ? Object.fromEntries(
- Object.entries(d.fields).map(([k, v]) => [k, ensureString(v, k)]),
- )
- : {};
-
- return {
- key: ensureString(d.key, 'key'),
- fileName: ensureString(d.file_name, 'file_name'),
- fileType: ensureString(d.file_type, 'file_type'),
- fileURL: ensureString(d.file_url, 'file_url'),
- contentDisposition: ensureString(
- d.content_disposition,
- 'content_disposition',
- ),
- pollingJWT: ensureString(d.polling_jwt, 'polling_jwt'),
- pollingURL: ensureString(d.polling_url, 'polling_url'),
- customId:
- d.custom_id != null ? ensureString(d.custom_id, 'custom_id') : undefined,
- URL: ensureString(d.url, 'url'),
- fields,
- };
-}
diff --git a/apps/admin/src/types/index.ts b/apps/admin/src/types/index.ts
index e6e89dc1..a6554346 100644
--- a/apps/admin/src/types/index.ts
+++ b/apps/admin/src/types/index.ts
@@ -1,4 +1,18 @@
-export function ensureString(value: unknown, name: string): string {
+/**
+ * Ensures a value is a string, coercing numbers and booleans if necessary.
+ * @param {Object} options - The options object
+ * @param {unknown} options.value - The value to validate and coerce
+ * @param {string} options.name - The property name for error messages
+ * @returns {string} The validated or coerced string value
+ * @throws {Error} If the value cannot be coerced to a string
+ */
+export function ensureString({
+ value,
+ name,
+}: {
+ value: unknown;
+ name: string;
+}): string {
if (typeof value === 'string') return value;
// Coerce numbers/booleans to strings for flexibility with JSON responses
if (typeof value === 'number' || typeof value === 'boolean') {
@@ -6,3 +20,49 @@ export function ensureString(value: unknown, name: string): string {
}
throw new Error(`Expected property '${name}' to be a string`);
}
+
+/**
+ * Ensures a value is a valid Date, coercing from string representation if necessary.
+ * @param {Object} options - The options object
+ * @param {unknown} options.value - The value to validate and coerce
+ * @param {string} options.name - The property name for error messages
+ * @returns {Date} The validated or coerced Date value
+ * @throws {Error} If the value cannot be coerced to a valid Date
+ */
+export function ensureDate({
+ value,
+ name,
+}: {
+ value: unknown;
+ name: string;
+}): Date {
+ const date = new Date(ensureString({ value, name }));
+ if (isNaN(date.getTime())) {
+ throw new Error(`Invalid date for property '${name}'`);
+ }
+
+ return date;
+}
+
+/**
+ * Ensures a value is a valid number, coercing from string representation if necessary.
+ * @param {Object} options - The options object
+ * @param {unknown} options.value - The value to validate and coerce
+ * @param {string} options.name - The property name for error messages
+ * @returns {number} The validated or coerced number value
+ * @throws {Error} If the value cannot be coerced to a valid number
+ */
+export function ensureNumber({
+ value,
+ name,
+}: {
+ value: unknown;
+ name: string;
+}): number {
+ const num = Number(ensureString({ value, name }));
+ if (isNaN(num)) {
+ throw new Error(`Invalid number for property '${name}'`);
+ }
+
+ return num;
+}
diff --git a/apps/admin/src/types/project.ts b/apps/admin/src/types/project.ts
index 18aaaa3d..196d6a0a 100644
--- a/apps/admin/src/types/project.ts
+++ b/apps/admin/src/types/project.ts
@@ -1,4 +1,5 @@
-import { ensureString } from '.';
+import { ensureDate, ensureString } from '.';
+import { type File, mapFile, toJSONFile } from './file';
export const ProjectType = {
web: 'web',
@@ -8,7 +9,7 @@ export const ProjectType = {
export type Project = {
id: string;
- preview: string;
+ previews: File[];
blurhash: string;
title: string;
subTitle: string;
@@ -21,6 +22,33 @@ export type Project = {
updatedAt: Date;
};
+/**
+ * Validates that a value is a valid project type.
+ *
+ * @param value - The value to validate as a project type
+ * @returns The validated project type value
+ * @throws {Error} If the value is not a string or is not a valid project type
+ */
+function ensureType(value: unknown): 'web' | 'mobile' | 'game' {
+ const typeValue = ensureString({
+ value: value,
+ name: 'type',
+ }) as (typeof ProjectType)[keyof typeof ProjectType];
+
+ if (!Object.values(ProjectType).includes(typeValue)) {
+ throw new Error(`Invalid project type: ${typeValue}`);
+ }
+
+ return typeValue;
+}
+
+/**
+ * Maps a DTO object to a Project domain object.
+ *
+ * @param dto - The data transfer object to map
+ * @returns The mapped Project domain object with validated properties
+ * @throws {Error} If the DTO is not a valid object or contains invalid properties
+ */
export function mapProject(dto: unknown): Project {
if (typeof dto !== 'object' || dto === null) {
throw new Error('Invalid project DTO');
@@ -28,53 +56,43 @@ export function mapProject(dto: unknown): Project {
const d = dto as Record;
- const typeValue = ensureString(
- d.type,
- 'type',
- ) as (typeof ProjectType)[keyof typeof ProjectType];
- if (!Object.values(ProjectType).includes(typeValue)) {
- throw new Error(`Invalid project type: ${typeValue}`);
- }
-
return {
- id: ensureString(d.id, 'id'),
- preview: ensureString(d.preview, 'preview'),
- blurhash: ensureString(d.blurhash, 'blurhash'),
- title: ensureString(d.title, 'title'),
- subTitle: ensureString(d.sub_title, 'sub_title'),
- description: ensureString(d.description, 'description'),
+ id: ensureString({ value: d.id, name: 'id' }),
+ previews: Array.isArray(d.previews)
+ ? d.previews.map((file) => mapFile(file))
+ : [],
+ blurhash: ensureString({ value: d.blurhash, name: 'blurhash' }),
+ title: ensureString({ value: d.title, name: 'title' }),
+ subTitle: ensureString({ value: d.sub_title, name: 'sub_title' }),
+ description: ensureString({ value: d.description, name: 'description' }),
tags: Array.isArray(d.tags)
- ? d.tags.map((tag, index) => ensureString(tag, `tags[${index}]`))
+ ? d.tags.map((tag, index) =>
+ ensureString({ value: tag, name: `tags[${index}]` }),
+ )
: [],
- type: typeValue,
- link: ensureString(d.link, 'link'),
+ type: ensureType(d.type),
+ link: ensureString({ value: d.link, name: 'link' }),
educationId:
d.education_id != null
- ? ensureString(d.education_id, 'education_id')
+ ? ensureString({ value: d.education_id, name: 'education_id' })
: undefined,
- createdAt: (() => {
- const date = new Date(ensureString(d.created_at, 'created_at'));
- if (isNaN(date.getTime())) {
- throw new Error('Invalid created_at date');
- }
- return date;
- })(),
- updatedAt: (() => {
- const date = new Date(ensureString(d.updated_at, 'updated_at'));
- if (isNaN(date.getTime())) {
- throw new Error('Invalid updated_at date');
- }
- return date;
- })(),
+ createdAt: ensureDate({ value: d.created_at, name: 'created_at' }),
+ updatedAt: ensureDate({ value: d.updated_at, name: 'updated_at' }),
};
}
+/**
+ * Converts a Project domain object to a JSON-serializable format.
+ *
+ * @param project - The Project domain object to convert
+ * @returns A record with snake_case keys suitable for API serialization, excluding undefined values
+ */
export function toJSONProject(
project: Partial,
): Record {
const result: Record = {
id: project.id,
- preview: project.preview,
+ previews: project.previews?.map((file) => toJSONFile(file)),
blurhash: project.blurhash,
title: project.title,
sub_title: project.subTitle,
diff --git a/apps/backend/.mockery.yaml b/apps/backend/.mockery.yaml
index 5857b042..91584fd0 100644
--- a/apps/backend/.mockery.yaml
+++ b/apps/backend/.mockery.yaml
@@ -15,7 +15,6 @@ packages:
ProjectRepository: {}
EducationRepository: {}
SkillRepository: {}
- ImageRepository: {}
FileRepository: {}
github.com/fingertips18/fingertips18.github.io/backend/internal/handler/v1:
interfaces:
@@ -24,7 +23,6 @@ packages:
ProjectHandler: {}
EducationHandler: {}
SkillHandler: {}
- ImageHandler: {}
FileHandler: {}
github.com/fingertips18/fingertips18.github.io/backend/internal/database:
interfaces:
diff --git a/apps/backend/docs/docs.go b/apps/backend/docs/docs.go
index 07613f35..51a3dd4e 100644
--- a/apps/backend/docs/docs.go
+++ b/apps/backend/docs/docs.go
@@ -511,6 +511,57 @@ const docTemplate = `{
}
}
},
+ "/file/upload": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Handles file upload with the supplied metadata and returns the Uploadthing URL of the stored file.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "file"
+ ],
+ "summary": "Upload a file",
+ "parameters": [
+ {
+ "description": "File upload payload",
+ "name": "fileUpload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.FileUploadRequestDTO"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": "File upload URL",
+ "schema": {
+ "$ref": "#/definitions/dto.FileUploadedResponseDTO"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/file/{id}": {
"get": {
"security": [
@@ -721,57 +772,6 @@ const docTemplate = `{
}
}
},
- "/image/upload": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Handles image upload with the supplied metadata and returns the Uploadthing URL of the stored image.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "image"
- ],
- "summary": "Upload an image",
- "parameters": [
- {
- "description": "Image upload payload",
- "name": "imageUpload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/v1.ImageUploadRequestDTO"
- }
- }
- ],
- "responses": {
- "202": {
- "description": "Image upload URL",
- "schema": {
- "$ref": "#/definitions/v1.ImageUploadResponseDTO"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- }
- }
- }
- },
"/project": {
"put": {
"security": [
@@ -1538,6 +1538,86 @@ const docTemplate = `{
}
}
},
+ "dto.FileUploadDTO": {
+ "type": "object",
+ "properties": {
+ "custom_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "size": {
+ "type": "integer"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
+ "dto.FileUploadRequestDTO": {
+ "type": "object",
+ "properties": {
+ "acl": {
+ "type": "string"
+ },
+ "content_disposition": {
+ "type": "string"
+ },
+ "files": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/dto.FileUploadDTO"
+ }
+ },
+ "metadata": {}
+ }
+ },
+ "dto.FileUploadedDTO": {
+ "type": "object",
+ "properties": {
+ "content_disposition": {
+ "type": "string"
+ },
+ "custom_id": {
+ "type": "string"
+ },
+ "fields": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "file_name": {
+ "type": "string"
+ },
+ "file_type": {
+ "type": "string"
+ },
+ "file_url": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string"
+ },
+ "polling_jwt": {
+ "type": "string"
+ },
+ "polling_url": {
+ "type": "string"
+ },
+ "url": {
+ "description": "signed URL to upload",
+ "type": "string"
+ }
+ }
+ },
+ "dto.FileUploadedResponseDTO": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "$ref": "#/definitions/dto.FileUploadedDTO"
+ }
+ }
+ },
"dto.ProjectDTO": {
"type": "object",
"properties": {
@@ -1688,23 +1768,6 @@ const docTemplate = `{
}
}
},
- "v1.FileDTO": {
- "type": "object",
- "properties": {
- "custom_id": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- },
- "type": {
- "type": "string"
- }
- }
- },
"v1.IDResponse": {
"type": "object",
"properties": {
@@ -1713,69 +1776,6 @@ const docTemplate = `{
}
}
},
- "v1.ImageUploadFileDTO": {
- "type": "object",
- "properties": {
- "content_disposition": {
- "type": "string"
- },
- "custom_id": {
- "type": "string"
- },
- "fields": {
- "type": "object",
- "additionalProperties": {}
- },
- "file_name": {
- "type": "string"
- },
- "file_type": {
- "type": "string"
- },
- "file_url": {
- "type": "string"
- },
- "key": {
- "type": "string"
- },
- "polling_jwt": {
- "type": "string"
- },
- "polling_url": {
- "type": "string"
- },
- "url": {
- "description": "signed URL to upload",
- "type": "string"
- }
- }
- },
- "v1.ImageUploadRequestDTO": {
- "type": "object",
- "properties": {
- "acl": {
- "type": "string"
- },
- "content_disposition": {
- "type": "string"
- },
- "files": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/v1.FileDTO"
- }
- },
- "metadata": {}
- }
- },
- "v1.ImageUploadResponseDTO": {
- "type": "object",
- "properties": {
- "file": {
- "$ref": "#/definitions/v1.ImageUploadFileDTO"
- }
- }
- },
"v1.SkillDTO": {
"type": "object",
"properties": {
diff --git a/apps/backend/docs/swagger.json b/apps/backend/docs/swagger.json
index bb8bacff..68fb09e2 100644
--- a/apps/backend/docs/swagger.json
+++ b/apps/backend/docs/swagger.json
@@ -503,6 +503,57 @@
}
}
},
+ "/file/upload": {
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ }
+ ],
+ "description": "Handles file upload with the supplied metadata and returns the Uploadthing URL of the stored file.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "file"
+ ],
+ "summary": "Upload a file",
+ "parameters": [
+ {
+ "description": "File upload payload",
+ "name": "fileUpload",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/dto.FileUploadRequestDTO"
+ }
+ }
+ ],
+ "responses": {
+ "202": {
+ "description": "File upload URL",
+ "schema": {
+ "$ref": "#/definitions/dto.FileUploadedResponseDTO"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/file/{id}": {
"get": {
"security": [
@@ -713,57 +764,6 @@
}
}
},
- "/image/upload": {
- "post": {
- "security": [
- {
- "ApiKeyAuth": []
- }
- ],
- "description": "Handles image upload with the supplied metadata and returns the Uploadthing URL of the stored image.",
- "consumes": [
- "application/json"
- ],
- "produces": [
- "application/json"
- ],
- "tags": [
- "image"
- ],
- "summary": "Upload an image",
- "parameters": [
- {
- "description": "Image upload payload",
- "name": "imageUpload",
- "in": "body",
- "required": true,
- "schema": {
- "$ref": "#/definitions/v1.ImageUploadRequestDTO"
- }
- }
- ],
- "responses": {
- "202": {
- "description": "Image upload URL",
- "schema": {
- "$ref": "#/definitions/v1.ImageUploadResponseDTO"
- }
- },
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- },
- "500": {
- "description": "Internal Server Error",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- }
- }
- }
- },
"/project": {
"put": {
"security": [
@@ -1530,6 +1530,86 @@
}
}
},
+ "dto.FileUploadDTO": {
+ "type": "object",
+ "properties": {
+ "custom_id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "size": {
+ "type": "integer"
+ },
+ "type": {
+ "type": "string"
+ }
+ }
+ },
+ "dto.FileUploadRequestDTO": {
+ "type": "object",
+ "properties": {
+ "acl": {
+ "type": "string"
+ },
+ "content_disposition": {
+ "type": "string"
+ },
+ "files": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/dto.FileUploadDTO"
+ }
+ },
+ "metadata": {}
+ }
+ },
+ "dto.FileUploadedDTO": {
+ "type": "object",
+ "properties": {
+ "content_disposition": {
+ "type": "string"
+ },
+ "custom_id": {
+ "type": "string"
+ },
+ "fields": {
+ "type": "object",
+ "additionalProperties": {}
+ },
+ "file_name": {
+ "type": "string"
+ },
+ "file_type": {
+ "type": "string"
+ },
+ "file_url": {
+ "type": "string"
+ },
+ "key": {
+ "type": "string"
+ },
+ "polling_jwt": {
+ "type": "string"
+ },
+ "polling_url": {
+ "type": "string"
+ },
+ "url": {
+ "description": "signed URL to upload",
+ "type": "string"
+ }
+ }
+ },
+ "dto.FileUploadedResponseDTO": {
+ "type": "object",
+ "properties": {
+ "file": {
+ "$ref": "#/definitions/dto.FileUploadedDTO"
+ }
+ }
+ },
"dto.ProjectDTO": {
"type": "object",
"properties": {
@@ -1680,23 +1760,6 @@
}
}
},
- "v1.FileDTO": {
- "type": "object",
- "properties": {
- "custom_id": {
- "type": "string"
- },
- "name": {
- "type": "string"
- },
- "size": {
- "type": "integer"
- },
- "type": {
- "type": "string"
- }
- }
- },
"v1.IDResponse": {
"type": "object",
"properties": {
@@ -1705,69 +1768,6 @@
}
}
},
- "v1.ImageUploadFileDTO": {
- "type": "object",
- "properties": {
- "content_disposition": {
- "type": "string"
- },
- "custom_id": {
- "type": "string"
- },
- "fields": {
- "type": "object",
- "additionalProperties": {}
- },
- "file_name": {
- "type": "string"
- },
- "file_type": {
- "type": "string"
- },
- "file_url": {
- "type": "string"
- },
- "key": {
- "type": "string"
- },
- "polling_jwt": {
- "type": "string"
- },
- "polling_url": {
- "type": "string"
- },
- "url": {
- "description": "signed URL to upload",
- "type": "string"
- }
- }
- },
- "v1.ImageUploadRequestDTO": {
- "type": "object",
- "properties": {
- "acl": {
- "type": "string"
- },
- "content_disposition": {
- "type": "string"
- },
- "files": {
- "type": "array",
- "items": {
- "$ref": "#/definitions/v1.FileDTO"
- }
- },
- "metadata": {}
- }
- },
- "v1.ImageUploadResponseDTO": {
- "type": "object",
- "properties": {
- "file": {
- "$ref": "#/definitions/v1.ImageUploadFileDTO"
- }
- }
- },
"v1.SkillDTO": {
"type": "object",
"properties": {
diff --git a/apps/backend/docs/swagger.yaml b/apps/backend/docs/swagger.yaml
index d469db8b..931f8414 100644
--- a/apps/backend/docs/swagger.yaml
+++ b/apps/backend/docs/swagger.yaml
@@ -120,6 +120,59 @@ definitions:
url:
type: string
type: object
+ dto.FileUploadDTO:
+ properties:
+ custom_id:
+ type: string
+ name:
+ type: string
+ size:
+ type: integer
+ type:
+ type: string
+ type: object
+ dto.FileUploadRequestDTO:
+ properties:
+ acl:
+ type: string
+ content_disposition:
+ type: string
+ files:
+ items:
+ $ref: '#/definitions/dto.FileUploadDTO'
+ type: array
+ metadata: {}
+ type: object
+ dto.FileUploadedDTO:
+ properties:
+ content_disposition:
+ type: string
+ custom_id:
+ type: string
+ fields:
+ additionalProperties: {}
+ type: object
+ file_name:
+ type: string
+ file_type:
+ type: string
+ file_url:
+ type: string
+ key:
+ type: string
+ polling_jwt:
+ type: string
+ polling_url:
+ type: string
+ url:
+ description: signed URL to upload
+ type: string
+ type: object
+ dto.FileUploadedResponseDTO:
+ properties:
+ file:
+ $ref: '#/definitions/dto.FileUploadedDTO'
+ type: object
dto.ProjectDTO:
properties:
blurhash:
@@ -219,64 +272,11 @@ definitions:
error:
type: string
type: object
- v1.FileDTO:
- properties:
- custom_id:
- type: string
- name:
- type: string
- size:
- type: integer
- type:
- type: string
- type: object
v1.IDResponse:
properties:
id:
type: string
type: object
- v1.ImageUploadFileDTO:
- properties:
- content_disposition:
- type: string
- custom_id:
- type: string
- fields:
- additionalProperties: {}
- type: object
- file_name:
- type: string
- file_type:
- type: string
- file_url:
- type: string
- key:
- type: string
- polling_jwt:
- type: string
- polling_url:
- type: string
- url:
- description: signed URL to upload
- type: string
- type: object
- v1.ImageUploadRequestDTO:
- properties:
- acl:
- type: string
- content_disposition:
- type: string
- files:
- items:
- $ref: '#/definitions/v1.FileDTO'
- type: array
- metadata: {}
- type: object
- v1.ImageUploadResponseDTO:
- properties:
- file:
- $ref: '#/definitions/v1.ImageUploadFileDTO'
- type: object
v1.SkillDTO:
properties:
category:
@@ -717,6 +717,39 @@ paths:
summary: Get a file by ID
tags:
- file
+ /file/upload:
+ post:
+ consumes:
+ - application/json
+ description: Handles file upload with the supplied metadata and returns the
+ Uploadthing URL of the stored file.
+ parameters:
+ - description: File upload payload
+ in: body
+ name: fileUpload
+ required: true
+ schema:
+ $ref: '#/definitions/dto.FileUploadRequestDTO'
+ produces:
+ - application/json
+ responses:
+ "202":
+ description: File upload URL
+ schema:
+ $ref: '#/definitions/dto.FileUploadedResponseDTO'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - ApiKeyAuth: []
+ summary: Upload a file
+ tags:
+ - file
/files:
delete:
description: Deletes all file records associated with a specific parent entity.
@@ -789,39 +822,6 @@ paths:
summary: List files by parent
tags:
- file
- /image/upload:
- post:
- consumes:
- - application/json
- description: Handles image upload with the supplied metadata and returns the
- Uploadthing URL of the stored image.
- parameters:
- - description: Image upload payload
- in: body
- name: imageUpload
- required: true
- schema:
- $ref: '#/definitions/v1.ImageUploadRequestDTO'
- produces:
- - application/json
- responses:
- "202":
- description: Image upload URL
- schema:
- $ref: '#/definitions/v1.ImageUploadResponseDTO'
- "400":
- description: Bad Request
- schema:
- $ref: '#/definitions/domain.ErrorResponse'
- "500":
- description: Internal Server Error
- schema:
- $ref: '#/definitions/domain.ErrorResponse'
- security:
- - ApiKeyAuth: []
- summary: Upload an image
- tags:
- - image
/project:
post:
consumes:
diff --git a/apps/backend/internal/domain/file.go b/apps/backend/internal/domain/file.go
index ba8e7e02..be9f6983 100644
--- a/apps/backend/internal/domain/file.go
+++ b/apps/backend/internal/domain/file.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/url"
"regexp"
+ "strconv"
"strings"
"time"
@@ -15,7 +16,6 @@ type ParentTable string
const (
ProjectTable ParentTable = "projects"
- UserTable ParentTable = "users"
EducationTable ParentTable = "educations"
// Add other valid parent table names as needed
)
@@ -44,19 +44,21 @@ type File struct {
UpdatedAt time.Time `json:"updated_at"`
}
+// isValid validates that the ParentTable is one of the allowed parent table types.
func (pt ParentTable) isValid() error {
if strings.TrimSpace(string(pt)) == "" {
return errors.New("parent_table missing")
}
switch pt {
- case ProjectTable, UserTable, EducationTable:
+ case ProjectTable, EducationTable:
return nil
default:
return errors.New("parent_table invalid")
}
}
+// isValid validates that the FileRole is one of the allowed file role types.
func (fr FileRole) isValid() error {
if strings.TrimSpace(string(fr)) == "" {
return errors.New("role missing")
@@ -70,6 +72,7 @@ func (fr FileRole) isValid() error {
}
}
+// isValidMimeType validates that the given MIME type string is in a valid format.
func isValidMimeType(mimeType string) error {
if strings.TrimSpace(mimeType) == "" {
return errors.New("type missing")
@@ -83,6 +86,7 @@ func isValidMimeType(mimeType string) error {
return errors.New("invalid type")
}
+// isValidUUID validates that the given id is a valid UUID format.
func isValidUUID(id string, label string) error {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("%s missing", label)
@@ -95,6 +99,7 @@ func isValidUUID(id string, label string) error {
return nil
}
+// isValidURL validates that the given value is a valid HTTP or HTTPS URL.
func isValidURL(value string) error {
value = strings.TrimSpace(value)
if value == "" {
@@ -115,6 +120,7 @@ func isValidURL(value string) error {
return nil
}
+// ValidatePayload validates the File payload fields required for creating or updating a file.
func (f File) ValidatePayload() error {
if err := f.ParentTable.isValid(); err != nil {
return err
@@ -140,6 +146,7 @@ func (f File) ValidatePayload() error {
return nil
}
+// ValidateResponse validates all File fields including those set by the server (id, created_at, updated_at).
func (f File) ValidateResponse() error {
if err := isValidUUID(f.ID, "id"); err != nil {
return err
@@ -158,3 +165,99 @@ func (f File) ValidateResponse() error {
}
return nil
}
+
+// -------------------- UPLOADTHING types below --------------------
+
+// FileUpload represents a single file to be uploaded via UploadThing.
+type FileUpload struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Type string `json:"type"`
+ CustomID *string `json:"customId,omitempty"`
+}
+
+// FileUploadRequest represents a request to upload files via UploadThing with optional metadata and configuration.
+type FileUploadRequest struct {
+ Files []FileUpload `json:"files"`
+ ACL *string `json:"acl,omitempty"`
+ Metadata any `json:"metadata,omitempty"`
+ ContentDisposition *string `json:"contentDisposition,omitempty"`
+}
+
+// Validate validates that the FileUploadRequest has all required fields and valid values.
+func (i FileUploadRequest) Validate() error {
+ // Files cannot be empty
+ if len(i.Files) == 0 {
+ return errors.New("files missing")
+ }
+
+ for idx, f := range i.Files {
+ if f.Name == "" {
+ return errors.New("file[" + strconv.Itoa(idx) + "]: name missing")
+ }
+ if f.Size <= 0 {
+ return errors.New("file[" + strconv.Itoa(idx) + "]: size invalid")
+ }
+ if f.Type == "" {
+ return errors.New("file[" + strconv.Itoa(idx) + "]: type missing")
+ }
+ if err := isValidMimeType(f.Type); err != nil {
+ return errors.New("file[" + strconv.Itoa(idx) + "]: " + err.Error())
+ }
+ }
+
+ // UploadThing ACL accepts: private, public-read
+ if i.ACL != nil && *i.ACL != "" {
+ if *i.ACL != "public-read" && *i.ACL != "private" {
+ return errors.New("acl must be 'public-read' or 'private'")
+ }
+ }
+
+ // UploadThing ContentDisposition accepts: inline, attachment
+ if i.ContentDisposition != nil && *i.ContentDisposition != "" {
+ if *i.ContentDisposition != "inline" && *i.ContentDisposition != "attachment" {
+ return errors.New("contentDisposition must be 'inline' or 'attachment'")
+ }
+ }
+
+ return nil
+}
+
+// FileUploaded represents a file that has been successfully uploaded via UploadThing.
+type FileUploaded struct {
+ Key string `json:"key"`
+ FileName string `json:"fileName"`
+ FileType string `json:"fileType"`
+ FileUrl string `json:"fileUrl"`
+ ContentDisposition string `json:"contentDisposition"`
+ PollingJwt string `json:"pollingJwt"`
+ PollingUrl string `json:"pollingUrl"`
+ CustomId *string `json:"customId,omitempty"`
+ URL string `json:"url"` // signed URL to upload
+ Fields map[string]any `json:"fields,omitempty"`
+}
+
+// FileUploadedResponse represents the response from UploadThing containing uploaded file information.
+type FileUploadedResponse struct {
+ Data []FileUploaded `json:"data"`
+}
+
+// Validate validates that the FileUploadedResponse has valid uploaded file data.
+func (r FileUploadedResponse) Validate() error {
+ if len(r.Data) == 0 {
+ return errors.New("uploadthing: response returned no files")
+ }
+
+ for idx, f := range r.Data {
+ prefix := fmt.Sprintf("uploadthing: data[%d]", idx)
+
+ if f.Key == "" {
+ return fmt.Errorf("%s.key missing", prefix)
+ }
+ if f.URL == "" {
+ return fmt.Errorf("%s.url missing", prefix)
+ }
+ }
+
+ return nil
+}
diff --git a/apps/backend/internal/domain/image.go b/apps/backend/internal/domain/image.go
deleted file mode 100644
index 3b520f21..00000000
--- a/apps/backend/internal/domain/image.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package domain
-
-import (
- "errors"
- "fmt"
- "strconv"
-)
-
-type ImageFile struct {
- Name string `json:"name"`
- Size int64 `json:"size"`
- Type string `json:"type"`
- CustomID *string `json:"customId,omitempty"`
-}
-
-type ImageUploadRequest struct {
- Files []ImageFile `json:"files"`
- ACL *string `json:"acl,omitempty"`
- Metadata any `json:"metadata,omitempty"`
- ContentDisposition *string `json:"contentDisposition,omitempty"`
-}
-
-func (i ImageUploadRequest) Validate() error {
- // Files cannot be empty
- if len(i.Files) == 0 {
- return errors.New("files missing")
- }
-
- for idx, f := range i.Files {
- if f.Name == "" {
- return errors.New("file[" + strconv.Itoa(idx) + "]: name missing")
- }
- if f.Size <= 0 {
- return errors.New("file[" + strconv.Itoa(idx) + "]: size invalid")
- }
- if f.Type == "" {
- return errors.New("file[" + strconv.Itoa(idx) + "]: type missing")
- }
- }
-
- // UploadThing ACL accepts: private, public-read
- if i.ACL != nil && *i.ACL != "" {
- if *i.ACL != "public-read" && *i.ACL != "private" {
- return errors.New("acl must be 'public-read' or 'private'")
- }
- }
-
- // UploadThing ContentDisposition accepts: inline, attachment
- if i.ContentDisposition != nil && *i.ContentDisposition != "" {
- if *i.ContentDisposition != "inline" && *i.ContentDisposition != "attachment" {
- return errors.New("contentDisposition must be 'inline' or 'attachment'")
- }
- }
-
- return nil
-}
-
-type ImageUploadFile struct {
- Key string `json:"key"`
- FileName string `json:"fileName"`
- FileType string `json:"fileType"`
- FileUrl string `json:"fileUrl"`
- ContentDisposition string `json:"contentDisposition"`
- PollingJwt string `json:"pollingJwt"`
- PollingUrl string `json:"pollingUrl"`
- CustomId *string `json:"customId,omitempty"`
- URL string `json:"url"` // signed URL to upload
- Fields map[string]any `json:"fields,omitempty"`
-}
-
-type ImageUploadResponse struct {
- Data []ImageUploadFile `json:"data"`
-}
-
-func (r ImageUploadResponse) Validate() error {
- if len(r.Data) == 0 {
- return errors.New("uploadthing: response returned no files")
- }
-
- for idx, f := range r.Data {
- prefix := fmt.Sprintf("uploadthing: data[%d]", idx)
-
- if f.Key == "" {
- return fmt.Errorf("%s.key missing", prefix)
- }
- if f.URL == "" {
- return fmt.Errorf("%s.url missing", prefix)
- }
- }
-
- return nil
-}
diff --git a/apps/backend/internal/handler/v1/dto.go b/apps/backend/internal/handler/v1/dto.go
index 6bd6dc6c..d8dc260c 100644
--- a/apps/backend/internal/handler/v1/dto.go
+++ b/apps/backend/internal/handler/v1/dto.go
@@ -45,37 +45,6 @@ type SkillFilterRequest struct {
Category string `json:"category"`
}
-type FileDTO struct {
- Name string `json:"name"`
- Size int64 `json:"size"`
- Type string `json:"type"`
- CustomID *string `json:"custom_id,omitempty"`
-}
-
-type ImageUploadRequestDTO struct {
- Files []FileDTO `json:"files"`
- ACL *string `json:"acl,omitempty"`
- Metadata any `json:"metadata,omitempty"`
- ContentDisposition *string `json:"content_disposition,omitempty"`
-}
-
-type ImageUploadFileDTO struct {
- Key string `json:"key"`
- FileName string `json:"file_name"`
- FileType string `json:"file_type"`
- FileUrl string `json:"file_url"`
- ContentDisposition string `json:"content_disposition"`
- PollingJwt string `json:"polling_jwt"`
- PollingUrl string `json:"polling_url"`
- CustomId *string `json:"custom_id,omitempty"`
- URL string `json:"url"` // signed URL to upload
- Fields map[string]any `json:"fields,omitempty"`
-}
-
-type ImageUploadResponseDTO struct {
- File ImageUploadFileDTO `json:"file"`
-}
-
type IDResponse struct {
Id string `json:"id"`
}
diff --git a/apps/backend/internal/handler/v1/dto/file.go b/apps/backend/internal/handler/v1/dto/file.go
index 86f546b4..c70556fe 100644
--- a/apps/backend/internal/handler/v1/dto/file.go
+++ b/apps/backend/internal/handler/v1/dto/file.go
@@ -24,3 +24,34 @@ type FileDTO struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
+
+type FileUploadDTO struct {
+ Name string `json:"name"`
+ Size int64 `json:"size"`
+ Type string `json:"type"`
+ CustomID *string `json:"custom_id,omitempty"`
+}
+
+type FileUploadRequestDTO struct {
+ Files []FileUploadDTO `json:"files"`
+ ACL *string `json:"acl,omitempty"`
+ Metadata any `json:"metadata,omitempty"`
+ ContentDisposition *string `json:"content_disposition,omitempty"`
+}
+
+type FileUploadedDTO struct {
+ Key string `json:"key"`
+ FileName string `json:"file_name"`
+ FileType string `json:"file_type"`
+ FileUrl string `json:"file_url"`
+ ContentDisposition string `json:"content_disposition"`
+ PollingJwt string `json:"polling_jwt"`
+ PollingUrl string `json:"polling_url"`
+ CustomId *string `json:"custom_id,omitempty"`
+ URL string `json:"url"` // signed URL to upload
+ Fields map[string]any `json:"fields,omitempty"`
+}
+
+type FileUploadedResponseDTO struct {
+ File FileUploadedDTO `json:"file"`
+}
diff --git a/apps/backend/internal/handler/v1/file.go b/apps/backend/internal/handler/v1/file.go
index 4fd4daab..d237e292 100644
--- a/apps/backend/internal/handler/v1/file.go
+++ b/apps/backend/internal/handler/v1/file.go
@@ -22,10 +22,12 @@ type FileHandler interface {
Delete(w http.ResponseWriter, r *http.Request, id string)
DeleteByParent(w http.ResponseWriter, r *http.Request, parentTable, parentID string)
ListByParent(w http.ResponseWriter, r *http.Request)
+ Upload(w http.ResponseWriter, r *http.Request)
}
type FileServiceConfig struct {
- DatabaseAPI database.DatabaseAPI
+ DatabaseAPI database.DatabaseAPI
+ UploadthingSecretKey string
fileRepo v1.FileRepository
}
@@ -44,8 +46,9 @@ func NewFileServiceHandler(cfg FileServiceConfig) FileHandler {
if fileRepo == nil {
fileRepo = v1.NewFileRepository(
v1.FileRepositoryConfig{
- DatabaseAPI: cfg.DatabaseAPI,
- FileTable: "File",
+ DatabaseAPI: cfg.DatabaseAPI,
+ FileTable: "File",
+ UploadthingSecretKey: cfg.UploadthingSecretKey,
},
)
}
@@ -68,6 +71,10 @@ func (h *fileServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimSuffix(r.URL.Path, "/")
switch {
+ case path == "/file/upload":
+ h.Upload(w, r)
+ return
+
// GET /files?parent_table=...&parent_id=...&role=...
case path == "/files":
switch r.Method {
@@ -458,3 +465,96 @@ func (h *fileServiceHandler) ListByParent(w http.ResponseWriter, r *http.Request
w.WriteHeader(http.StatusOK)
w.Write(buf.Bytes())
}
+
+// Upload handles HTTP POST requests to upload file files.
+// It expects a JSON request body containing file metadata and upload configuration.
+// The method validates the HTTP method, decodes the request body, converts DTOs to domain objects,
+// and delegates the upload operation to the file repository.
+// On success, it returns a 202 Accepted status with a JSON response containing the uploaded URL.
+// On failure, it returns appropriate HTTP error status codes with error messages.
+//
+// @Security ApiKeyAuth
+// @Summary Upload a file
+// @Description Handles file upload with the supplied metadata and returns the Uploadthing URL of the stored file.
+// @Tags file
+// @Accept json
+// @Produce json
+// @Param fileUpload body dto.FileUploadRequestDTO true "File upload payload"
+// @Success 202 {object} dto.FileUploadedResponseDTO "File upload URL"
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /file/upload [post]
+func (h *fileServiceHandler) Upload(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed: only POST is supported", http.StatusMethodNotAllowed)
+ return
+ }
+
+ var req dto.FileUploadRequestDTO
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "Invalid JSON in request body", http.StatusBadRequest)
+ return
+ }
+
+ var files []domain.FileUpload
+ for _, f := range req.Files {
+ files = append(files, domain.FileUpload{
+ Name: f.Name,
+ Size: f.Size,
+ Type: f.Type,
+ CustomID: f.CustomID,
+ })
+ }
+
+ upload := domain.FileUploadRequest{
+ Files: files,
+ ACL: req.ACL,
+ Metadata: req.Metadata,
+ ContentDisposition: req.ContentDisposition,
+ }
+
+ if err := upload.Validate(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ uploaded, err := h.fileRepo.Upload(r.Context(), &upload)
+ if err != nil {
+ // The error in the repo is comprehensive enough
+ // Ensure that the first letter is capitalize
+ msg := err.Error()
+ if len(msg) > 0 {
+ msg = strings.ToUpper(msg[:1]) + msg[1:]
+ }
+
+ http.Error(w, msg, http.StatusInternalServerError)
+ return
+ }
+
+ file := dto.FileUploadedDTO{
+ Key: uploaded.Key,
+ FileName: uploaded.FileName,
+ FileType: uploaded.FileType,
+ FileUrl: uploaded.FileUrl,
+ ContentDisposition: uploaded.ContentDisposition,
+ PollingJwt: uploaded.PollingJwt,
+ PollingUrl: uploaded.PollingUrl,
+ CustomId: uploaded.CustomId,
+ URL: uploaded.URL,
+ Fields: uploaded.Fields,
+ }
+
+ resp := dto.FileUploadedResponseDTO{
+ File: file,
+ }
+
+ var buf bytes.Buffer
+ if err := json.NewEncoder(&buf).Encode(resp); err != nil {
+ http.Error(w, "Failed to write response: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusAccepted)
+ w.Write(buf.Bytes())
+}
diff --git a/apps/backend/internal/handler/v1/image_test.go b/apps/backend/internal/handler/v1/file_test.go
similarity index 58%
rename from apps/backend/internal/handler/v1/image_test.go
rename to apps/backend/internal/handler/v1/file_test.go
index 3cb3efae..99d28e73 100644
--- a/apps/backend/internal/handler/v1/image_test.go
+++ b/apps/backend/internal/handler/v1/file_test.go
@@ -11,47 +11,48 @@ import (
"testing"
"github.com/fingertips18/fingertips18.github.io/backend/internal/domain"
+ "github.com/fingertips18/fingertips18.github.io/backend/internal/handler/v1/dto"
mockRepo "github.com/fingertips18/fingertips18.github.io/backend/internal/repository/v1/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
-type imageHandlerTestFixture struct {
- t *testing.T
- mockImageRepo *mockRepo.MockImageRepository
- imageHandler ImageHandler
+type fileHandlerTestFixture struct {
+ t *testing.T
+ mockFileRepo *mockRepo.MockFileRepository
+ fileHandler FileHandler
}
-func newImageHandlerTestFixture(t *testing.T) *imageHandlerTestFixture {
- mockImageRepo := new(mockRepo.MockImageRepository)
+func newFileHandlerTestFixture(t *testing.T) *fileHandlerTestFixture {
+ mockFileRepo := new(mockRepo.MockFileRepository)
- imageHandler := NewImageServiceHandler(
- ImageServiceConfig{
- imageRepo: mockImageRepo,
+ fileHandler := NewFileServiceHandler(
+ FileServiceConfig{
+ fileRepo: mockFileRepo,
},
)
- return &imageHandlerTestFixture{
- t: t,
- mockImageRepo: mockImageRepo,
- imageHandler: imageHandler,
+ return &fileHandlerTestFixture{
+ t: t,
+ mockFileRepo: mockFileRepo,
+ fileHandler: fileHandler,
}
}
-func TestImageServiceHandler_Upload(t *testing.T) {
- validFile := FileDTO{
+func TestFileServiceHandler_Upload(t *testing.T) {
+ validFile := dto.FileUploadDTO{
Name: "profile.jpg",
Size: 1024,
Type: "image/jpeg",
}
- validReq := ImageUploadRequestDTO{
- Files: []FileDTO{validFile},
+ validReq := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{validFile},
}
validBody, _ := json.Marshal(validReq)
customID := "custom-123"
- expectedImageUploadFile := &domain.ImageUploadFile{
+ expectedUploadedFile := &domain.FileUploaded{
Key: "abc123",
FileName: "profile.jpg",
FileType: "image/jpeg",
@@ -64,25 +65,25 @@ func TestImageServiceHandler_Upload(t *testing.T) {
Fields: map[string]interface{}{"key": "value"},
}
- expectedResp, _ := json.Marshal(ImageUploadResponseDTO{
- File: ImageUploadFileDTO{
- Key: expectedImageUploadFile.Key,
- FileName: expectedImageUploadFile.FileName,
- FileType: expectedImageUploadFile.FileType,
- FileUrl: expectedImageUploadFile.FileUrl,
- ContentDisposition: expectedImageUploadFile.ContentDisposition,
- PollingJwt: expectedImageUploadFile.PollingJwt,
- PollingUrl: expectedImageUploadFile.PollingUrl,
- CustomId: expectedImageUploadFile.CustomId,
- URL: expectedImageUploadFile.URL,
- Fields: expectedImageUploadFile.Fields,
+ expectedResp, _ := json.Marshal(dto.FileUploadedResponseDTO{
+ File: dto.FileUploadedDTO{
+ Key: expectedUploadedFile.Key,
+ FileName: expectedUploadedFile.FileName,
+ FileType: expectedUploadedFile.FileType,
+ FileUrl: expectedUploadedFile.FileUrl,
+ ContentDisposition: expectedUploadedFile.ContentDisposition,
+ PollingJwt: expectedUploadedFile.PollingJwt,
+ PollingUrl: expectedUploadedFile.PollingUrl,
+ CustomId: expectedUploadedFile.CustomId,
+ URL: expectedUploadedFile.URL,
+ Fields: expectedUploadedFile.Fields,
},
})
type Given struct {
method string
body string
- mockRepo func(m *mockRepo.MockImageRepository)
+ mockRepo func(m *mockRepo.MockFileRepository)
}
type Expected struct {
code int
@@ -97,15 +98,15 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: string(validBody),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 1 &&
req.Files[0].Name == "profile.jpg" &&
req.Files[0].Size == 1024 &&
req.Files[0].Type == "image/jpeg"
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -147,9 +148,9 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: string(validBody),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.AnythingOfType("*domain.ImageUploadRequest")).
+ Upload(mock.Anything, mock.AnythingOfType("*domain.FileUploadRequest")).
Return(nil, errors.New("upload failed"))
},
},
@@ -162,8 +163,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{
{Name: "image1.jpg", Size: 1024, Type: "image/jpeg"},
{Name: "image2.png", Size: 2048, Type: "image/png"},
{Name: "image3.gif", Size: 512, Type: "image/gif"},
@@ -172,15 +173,15 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 3 &&
req.Files[0].Name == "image1.jpg" &&
req.Files[1].Name == "image2.png" &&
req.Files[2].Name == "image3.gif"
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -192,23 +193,16 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{},
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{},
}
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
- m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
- return len(req.Files) == 0
- })).
- Return(expectedImageUploadFile, nil)
- },
},
expected: Expected{
- code: http.StatusAccepted,
- body: string(expectedResp),
+ code: http.StatusBadRequest,
+ body: "files missing\n",
},
},
"with all optional fields": {
@@ -217,8 +211,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
body: func() string {
acl := "public-read"
contentDisposition := "inline"
- req := ImageUploadRequestDTO{
- Files: []FileDTO{validFile},
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{validFile},
ACL: &acl,
ContentDisposition: &contentDisposition,
Metadata: map[string]string{
@@ -229,16 +223,16 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return req.ACL != nil &&
*req.ACL == "public-read" &&
req.ContentDisposition != nil &&
*req.ContentDisposition == "inline" &&
req.Metadata != nil
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -251,8 +245,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
method: http.MethodPost,
body: func() string {
customID := "custom-file-id-123"
- req := ImageUploadRequestDTO{
- Files: []FileDTO{
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{
{
Name: "profile.jpg",
Size: 1024,
@@ -264,14 +258,14 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 1 &&
req.Files[0].CustomID != nil &&
*req.Files[0].CustomID == "custom-file-id-123"
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -283,8 +277,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{
{
Name: "large-image.jpg",
Size: 104857600, // 100MB
@@ -295,12 +289,12 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 1 && req.Files[0].Size == 104857600
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -312,8 +306,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{
{
Name: "画像ファイル-🖼️.jpg",
Size: 1024,
@@ -324,13 +318,13 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 1 &&
strings.Contains(req.Files[0].Name, "画像ファイル")
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -342,8 +336,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{
{
Name: strings.Repeat("a", 500) + ".jpg",
Size: 1024,
@@ -354,12 +348,12 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 1 && len(req.Files[0].Name) > 500
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -371,8 +365,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{
{Name: "image.jpg", Size: 1024, Type: "image/jpeg"},
{Name: "image.png", Size: 2048, Type: "image/png"},
{Name: "image.gif", Size: 512, Type: "image/gif"},
@@ -383,12 +377,12 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 5
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -400,10 +394,10 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: `{"files":[{"name":"test.jpg","size":1024,"type":"image/jpeg"}],"extra_field":"ignored"}`,
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.AnythingOfType("*domain.ImageUploadRequest")).
- Return(expectedImageUploadFile, nil)
+ Upload(mock.Anything, mock.AnythingOfType("*domain.FileUploadRequest")).
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -415,8 +409,8 @@ func TestImageServiceHandler_Upload(t *testing.T) {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{
{
Name: "empty.jpg",
Size: 0,
@@ -427,25 +421,18 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
- m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
- return len(req.Files) == 1 && req.Files[0].Size == 0
- })).
- Return(expectedImageUploadFile, nil)
- },
},
expected: Expected{
- code: http.StatusAccepted,
- body: string(expectedResp),
+ code: http.StatusBadRequest,
+ body: "file[0]: size invalid\n",
},
},
"complex metadata": {
given: Given{
method: http.MethodPost,
body: func() string {
- req := ImageUploadRequestDTO{
- Files: []FileDTO{validFile},
+ req := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{validFile},
Metadata: map[string]interface{}{
"user": map[string]string{
"id": "123",
@@ -459,12 +446,12 @@ func TestImageServiceHandler_Upload(t *testing.T) {
b, _ := json.Marshal(req)
return string(b)
}(),
- mockRepo: func(m *mockRepo.MockImageRepository) {
+ mockRepo: func(m *mockRepo.MockFileRepository) {
m.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return req.Metadata != nil
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
},
},
expected: Expected{
@@ -476,16 +463,16 @@ func TestImageServiceHandler_Upload(t *testing.T) {
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
- f := newImageHandlerTestFixture(t)
+ f := newFileHandlerTestFixture(t)
if tt.given.mockRepo != nil {
- tt.given.mockRepo(f.mockImageRepo)
+ tt.given.mockRepo(f.mockFileRepo)
}
- req := httptest.NewRequest(tt.given.method, "/image/upload", strings.NewReader(tt.given.body))
+ req := httptest.NewRequest(tt.given.method, "/file/upload", strings.NewReader(tt.given.body))
w := httptest.NewRecorder()
- f.imageHandler.Upload(w, req)
+ f.fileHandler.Upload(w, req)
res := w.Result()
defer res.Body.Close()
@@ -499,25 +486,25 @@ func TestImageServiceHandler_Upload(t *testing.T) {
assert.Equal(t, tt.expected.body, string(body))
}
- f.mockImageRepo.AssertExpectations(t)
+ f.mockFileRepo.AssertExpectations(t)
})
}
}
-func TestImageServiceHandler_Upload_Routing(t *testing.T) {
- validFile := FileDTO{
+func TestFileServiceHandler_Upload_Routing(t *testing.T) {
+ validFile := dto.FileUploadDTO{
Name: "profile.jpg",
Size: 1024,
Type: "image/jpeg",
}
- validReq := ImageUploadRequestDTO{
- Files: []FileDTO{validFile},
+ validReq := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{validFile},
}
validBody, _ := json.Marshal(validReq)
customID := "custom-123"
- expectedImageUploadFile := &domain.ImageUploadFile{
+ expectedUploadedFile := &domain.FileUploaded{
Key: "abc123",
FileName: "profile.jpg",
FileType: "image/jpeg",
@@ -530,38 +517,38 @@ func TestImageServiceHandler_Upload_Routing(t *testing.T) {
Fields: map[string]interface{}{"key": "value"},
}
- expectedResp, _ := json.Marshal(ImageUploadResponseDTO{
- File: ImageUploadFileDTO{
- Key: expectedImageUploadFile.Key,
- FileName: expectedImageUploadFile.FileName,
- FileType: expectedImageUploadFile.FileType,
- FileUrl: expectedImageUploadFile.FileUrl,
- ContentDisposition: expectedImageUploadFile.ContentDisposition,
- PollingJwt: expectedImageUploadFile.PollingJwt,
- PollingUrl: expectedImageUploadFile.PollingUrl,
- CustomId: expectedImageUploadFile.CustomId,
- URL: expectedImageUploadFile.URL,
- Fields: expectedImageUploadFile.Fields,
+ expectedResp, _ := json.Marshal(dto.FileUploadedResponseDTO{
+ File: dto.FileUploadedDTO{
+ Key: expectedUploadedFile.Key,
+ FileName: expectedUploadedFile.FileName,
+ FileType: expectedUploadedFile.FileType,
+ FileUrl: expectedUploadedFile.FileUrl,
+ ContentDisposition: expectedUploadedFile.ContentDisposition,
+ PollingJwt: expectedUploadedFile.PollingJwt,
+ PollingUrl: expectedUploadedFile.PollingUrl,
+ CustomId: expectedUploadedFile.CustomId,
+ URL: expectedUploadedFile.URL,
+ Fields: expectedUploadedFile.Fields,
},
})
- f := newImageHandlerTestFixture(t)
+ f := newFileHandlerTestFixture(t)
// Mock expectation
- f.mockImageRepo.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ f.mockFileRepo.EXPECT().
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 1 &&
req.Files[0].Name == "profile.jpg"
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
// Create request
- req := httptest.NewRequest(http.MethodPost, "/image/upload", bytes.NewReader(validBody))
+ req := httptest.NewRequest(http.MethodPost, "/file/upload", bytes.NewReader(validBody))
w := httptest.NewRecorder()
// Verify handler implements http.Handler
- handler, ok := f.imageHandler.(http.Handler)
- assert.True(t, ok, "imageHandler should implement http.Handler")
+ handler, ok := f.fileHandler.(http.Handler)
+ assert.True(t, ok, "fileHandler should implement http.Handler")
// Route through ServeHTTP
handler.ServeHTTP(w, req)
@@ -574,20 +561,20 @@ func TestImageServiceHandler_Upload_Routing(t *testing.T) {
assert.Equal(t, http.StatusAccepted, res.StatusCode)
assert.JSONEq(t, string(expectedResp), string(body))
- f.mockImageRepo.AssertExpectations(t)
+ f.mockFileRepo.AssertExpectations(t)
}
-func TestImageServiceHandler_ServeHTTP_NotFound(t *testing.T) {
- f := newImageHandlerTestFixture(t)
+func TestFileServiceHandler_ServeHTTP_NotFound(t *testing.T) {
+ f := newFileHandlerTestFixture(t)
tests := []struct {
name string
path string
}{
- {"root path", "/image"},
- {"invalid path", "/image/invalid"},
- {"nested path", "/image/upload/extra"},
- {"different path", "/image/download"},
+ {"root path", "/upload"},
+ {"invalid path", "/upload/invalid"},
+ {"nested path", "/upload/upload/extra"},
+ {"different path", "/upload/download"},
}
for _, tt := range tests {
@@ -595,9 +582,8 @@ func TestImageServiceHandler_ServeHTTP_NotFound(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tt.path, nil)
w := httptest.NewRecorder()
- handler, ok := f.imageHandler.(http.Handler)
- assert.True(t, ok, "imageHandler should implement http.Handler")
-
+ handler, ok := f.fileHandler.(http.Handler)
+ assert.True(t, ok, "fileHandler should implement http.Handler")
handler.ServeHTTP(w, req)
res := w.Result()
@@ -608,20 +594,20 @@ func TestImageServiceHandler_ServeHTTP_NotFound(t *testing.T) {
}
}
-func TestImageServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) {
- validFile := FileDTO{
+func TestFileServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) {
+ validFile := dto.FileUploadDTO{
Name: "profile.jpg",
Size: 1024,
Type: "image/jpeg",
}
- validReq := ImageUploadRequestDTO{
- Files: []FileDTO{validFile},
+ validReq := dto.FileUploadRequestDTO{
+ Files: []dto.FileUploadDTO{validFile},
}
validBody, _ := json.Marshal(validReq)
customID := "custom-123"
- expectedImageUploadFile := &domain.ImageUploadFile{
+ expectedUploadedFile := &domain.FileUploaded{
Key: "abc123",
FileName: "profile.jpg",
FileType: "image/jpeg",
@@ -634,36 +620,36 @@ func TestImageServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) {
Fields: map[string]interface{}{"key": "value"},
}
- expectedResp, _ := json.Marshal(ImageUploadResponseDTO{
- File: ImageUploadFileDTO{
- Key: expectedImageUploadFile.Key,
- FileName: expectedImageUploadFile.FileName,
- FileType: expectedImageUploadFile.FileType,
- FileUrl: expectedImageUploadFile.FileUrl,
- ContentDisposition: expectedImageUploadFile.ContentDisposition,
- PollingJwt: expectedImageUploadFile.PollingJwt,
- PollingUrl: expectedImageUploadFile.PollingUrl,
- CustomId: expectedImageUploadFile.CustomId,
- URL: expectedImageUploadFile.URL,
- Fields: expectedImageUploadFile.Fields,
+ expectedResp, _ := json.Marshal(dto.FileUploadedResponseDTO{
+ File: dto.FileUploadedDTO{
+ Key: expectedUploadedFile.Key,
+ FileName: expectedUploadedFile.FileName,
+ FileType: expectedUploadedFile.FileType,
+ FileUrl: expectedUploadedFile.FileUrl,
+ ContentDisposition: expectedUploadedFile.ContentDisposition,
+ PollingJwt: expectedUploadedFile.PollingJwt,
+ PollingUrl: expectedUploadedFile.PollingUrl,
+ CustomId: expectedUploadedFile.CustomId,
+ URL: expectedUploadedFile.URL,
+ Fields: expectedUploadedFile.Fields,
},
})
- f := newImageHandlerTestFixture(t)
+ f := newFileHandlerTestFixture(t)
// Mock expectation
- f.mockImageRepo.EXPECT().
- Upload(mock.Anything, mock.MatchedBy(func(req *domain.ImageUploadRequest) bool {
+ f.mockFileRepo.EXPECT().
+ Upload(mock.Anything, mock.MatchedBy(func(req *domain.FileUploadRequest) bool {
return len(req.Files) == 1
})).
- Return(expectedImageUploadFile, nil)
+ Return(expectedUploadedFile, nil)
// Create request with trailing slash
- req := httptest.NewRequest(http.MethodPost, "/image/upload/", bytes.NewReader(validBody))
+ req := httptest.NewRequest(http.MethodPost, "/file/upload/", bytes.NewReader(validBody))
w := httptest.NewRecorder()
- handler, ok := f.imageHandler.(http.Handler)
- assert.True(t, ok, "imageHandler should implement http.Handler")
+ handler, ok := f.fileHandler.(http.Handler)
+ assert.True(t, ok, "fileHandler should implement http.Handler")
handler.ServeHTTP(w, req)
@@ -674,5 +660,5 @@ func TestImageServiceHandler_ServeHTTP_TrailingSlash(t *testing.T) {
assert.Equal(t, http.StatusAccepted, res.StatusCode)
assert.JSONEq(t, string(expectedResp), string(body))
- f.mockImageRepo.AssertExpectations(t)
+ f.mockFileRepo.AssertExpectations(t)
}
diff --git a/apps/backend/internal/handler/v1/image.go b/apps/backend/internal/handler/v1/image.go
deleted file mode 100644
index fba12efa..00000000
--- a/apps/backend/internal/handler/v1/image.go
+++ /dev/null
@@ -1,152 +0,0 @@
-package v1
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "strings"
-
- "github.com/fingertips18/fingertips18.github.io/backend/internal/domain"
- v1 "github.com/fingertips18/fingertips18.github.io/backend/internal/repository/v1"
-)
-
-type ImageHandler interface {
- http.Handler
- Upload(w http.ResponseWriter, r *http.Request)
-}
-
-type ImageServiceConfig struct {
- UploadthingSecretKey string
-
- imageRepo v1.ImageRepository
-}
-
-type imageServiceHandler struct {
- imageRepo v1.ImageRepository
-}
-
-// NewImageServiceHandler returns an ImageHandler configured from the provided cfg.
-// If cfg.imageRepo is nil, a default v1.ImageRepository is created using
-// cfg.UploadthingSecretKey. The resulting ImageHandler is an *imageServiceHandler
-// whose imageRepo field is set to the provided or constructed repository.
-func NewImageServiceHandler(cfg ImageServiceConfig) ImageHandler {
- imageRepo := cfg.imageRepo
- if imageRepo == nil {
- imageRepo = v1.NewImageRepository(
- v1.ImageRepositoryConfig{
- UploadthingSecretKey: cfg.UploadthingSecretKey,
- },
- )
- }
-
- return &imageServiceHandler{
- imageRepo: imageRepo,
- }
-}
-
-// ServeHTTP handles HTTP requests for image operations.
-// It routes requests to appropriate handlers based on the URL path.
-// Supported routes:
-// - POST /image/upload: Uploads an image
-//
-// For unrecognized paths, it returns a 404 Not Found response.
-func (h *imageServiceHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
- path := strings.TrimSuffix(r.URL.Path, "/")
- path = strings.TrimPrefix(path, "/image")
-
- switch path {
- case "/upload":
- h.Upload(w, r)
- default:
- http.NotFound(w, r)
- }
-}
-
-// Upload handles HTTP POST requests to upload image files.
-// It expects a JSON request body containing file metadata and upload configuration.
-// The method validates the HTTP method, decodes the request body, converts DTOs to domain objects,
-// and delegates the upload operation to the image repository.
-// On success, it returns a 202 Accepted status with a JSON response containing the uploaded URL.
-// On failure, it returns appropriate HTTP error status codes with error messages.
-//
-// @Security ApiKeyAuth
-// @Summary Upload an image
-// @Description Handles image upload with the supplied metadata and returns the Uploadthing URL of the stored image.
-// @Tags image
-// @Accept json
-// @Produce json
-// @Param imageUpload body ImageUploadRequestDTO true "Image upload payload"
-// @Success 202 {object} ImageUploadResponseDTO "Image upload URL"
-// @Failure 400 {object} domain.ErrorResponse
-// @Failure 500 {object} domain.ErrorResponse
-// @Router /image/upload [post]
-func (h *imageServiceHandler) Upload(w http.ResponseWriter, r *http.Request) {
- if r.Method != http.MethodPost {
- http.Error(w, "Method not allowed: only POST is supported", http.StatusMethodNotAllowed)
- return
- }
-
- defer r.Body.Close()
-
- var req ImageUploadRequestDTO
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
- http.Error(w, "Invalid JSON in request body", http.StatusBadRequest)
- return
- }
-
- var files []domain.ImageFile
- for _, f := range req.Files {
- files = append(files, domain.ImageFile{
- Name: f.Name,
- Size: f.Size,
- Type: f.Type,
- CustomID: f.CustomID,
- })
- }
- upload := domain.ImageUploadRequest{
- Files: files,
- ACL: req.ACL,
- Metadata: req.Metadata,
- ContentDisposition: req.ContentDisposition,
- }
-
- image, err := h.imageRepo.Upload(r.Context(), &upload)
- if err != nil {
- // The error in the repo is comprehensive enough
- // Ensure that the first letter is capitalize
- msg := err.Error()
- if len(msg) > 0 {
- msg = strings.ToUpper(msg[:1]) + msg[1:]
- }
-
- http.Error(w, msg, http.StatusInternalServerError)
- return
- }
-
- file := ImageUploadFileDTO{
- Key: image.Key,
- FileName: image.FileName,
- FileType: image.FileType,
- FileUrl: image.FileUrl,
- ContentDisposition: image.ContentDisposition,
- PollingJwt: image.PollingJwt,
- PollingUrl: image.PollingUrl,
- CustomId: image.CustomId,
- URL: image.URL,
- Fields: image.Fields,
- }
-
- resp := ImageUploadResponseDTO{
- File: file,
- }
-
- var buf bytes.Buffer
- if err := json.NewEncoder(&buf).Encode(resp); err != nil {
- http.Error(w, "Failed to write response: "+err.Error(), http.StatusInternalServerError)
- return
- }
-
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusAccepted)
- w.Write(buf.Bytes())
-}
diff --git a/apps/backend/internal/handler/v1/mocks/file_handler.go b/apps/backend/internal/handler/v1/mocks/file_handler.go
index 8b857a66..cc471d45 100644
--- a/apps/backend/internal/handler/v1/mocks/file_handler.go
+++ b/apps/backend/internal/handler/v1/mocks/file_handler.go
@@ -382,3 +382,49 @@ func (_c *MockFileHandler_Update_Call) RunAndReturn(run func(w http.ResponseWrit
_c.Run(run)
return _c
}
+
+// Upload provides a mock function for the type MockFileHandler
+func (_mock *MockFileHandler) Upload(w http.ResponseWriter, r *http.Request) {
+ _mock.Called(w, r)
+ return
+}
+
+// MockFileHandler_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload'
+type MockFileHandler_Upload_Call struct {
+ *mock.Call
+}
+
+// Upload is a helper method to define mock.On call
+// - w http.ResponseWriter
+// - r *http.Request
+func (_e *MockFileHandler_Expecter) Upload(w interface{}, r interface{}) *MockFileHandler_Upload_Call {
+ return &MockFileHandler_Upload_Call{Call: _e.mock.On("Upload", w, r)}
+}
+
+func (_c *MockFileHandler_Upload_Call) Run(run func(w http.ResponseWriter, r *http.Request)) *MockFileHandler_Upload_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 http.ResponseWriter
+ if args[0] != nil {
+ arg0 = args[0].(http.ResponseWriter)
+ }
+ var arg1 *http.Request
+ if args[1] != nil {
+ arg1 = args[1].(*http.Request)
+ }
+ run(
+ arg0,
+ arg1,
+ )
+ })
+ return _c
+}
+
+func (_c *MockFileHandler_Upload_Call) Return() *MockFileHandler_Upload_Call {
+ _c.Call.Return()
+ return _c
+}
+
+func (_c *MockFileHandler_Upload_Call) RunAndReturn(run func(w http.ResponseWriter, r *http.Request)) *MockFileHandler_Upload_Call {
+ _c.Run(run)
+ return _c
+}
diff --git a/apps/backend/internal/handler/v1/mocks/image_handler.go b/apps/backend/internal/handler/v1/mocks/image_handler.go
deleted file mode 100644
index de0bfe70..00000000
--- a/apps/backend/internal/handler/v1/mocks/image_handler.go
+++ /dev/null
@@ -1,130 +0,0 @@
-// Code generated by mockery; DO NOT EDIT.
-// github.com/vektra/mockery
-// template: testify
-
-package v1
-
-import (
- "net/http"
-
- mock "github.com/stretchr/testify/mock"
-)
-
-// NewMockImageHandler creates a new instance of MockImageHandler. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockImageHandler(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockImageHandler {
- mock := &MockImageHandler{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
-
-// MockImageHandler is an autogenerated mock type for the ImageHandler type
-type MockImageHandler struct {
- mock.Mock
-}
-
-type MockImageHandler_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockImageHandler) EXPECT() *MockImageHandler_Expecter {
- return &MockImageHandler_Expecter{mock: &_m.Mock}
-}
-
-// ServeHTTP provides a mock function for the type MockImageHandler
-func (_mock *MockImageHandler) ServeHTTP(responseWriter http.ResponseWriter, request *http.Request) {
- _mock.Called(responseWriter, request)
- return
-}
-
-// MockImageHandler_ServeHTTP_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ServeHTTP'
-type MockImageHandler_ServeHTTP_Call struct {
- *mock.Call
-}
-
-// ServeHTTP is a helper method to define mock.On call
-// - responseWriter http.ResponseWriter
-// - request *http.Request
-func (_e *MockImageHandler_Expecter) ServeHTTP(responseWriter interface{}, request interface{}) *MockImageHandler_ServeHTTP_Call {
- return &MockImageHandler_ServeHTTP_Call{Call: _e.mock.On("ServeHTTP", responseWriter, request)}
-}
-
-func (_c *MockImageHandler_ServeHTTP_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockImageHandler_ServeHTTP_Call {
- _c.Call.Run(func(args mock.Arguments) {
- var arg0 http.ResponseWriter
- if args[0] != nil {
- arg0 = args[0].(http.ResponseWriter)
- }
- var arg1 *http.Request
- if args[1] != nil {
- arg1 = args[1].(*http.Request)
- }
- run(
- arg0,
- arg1,
- )
- })
- return _c
-}
-
-func (_c *MockImageHandler_ServeHTTP_Call) Return() *MockImageHandler_ServeHTTP_Call {
- _c.Call.Return()
- return _c
-}
-
-func (_c *MockImageHandler_ServeHTTP_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockImageHandler_ServeHTTP_Call {
- _c.Run(run)
- return _c
-}
-
-// Upload provides a mock function for the type MockImageHandler
-func (_mock *MockImageHandler) Upload(w http.ResponseWriter, r *http.Request) {
- _mock.Called(w, r)
- return
-}
-
-// MockImageHandler_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload'
-type MockImageHandler_Upload_Call struct {
- *mock.Call
-}
-
-// Upload is a helper method to define mock.On call
-// - w http.ResponseWriter
-// - r *http.Request
-func (_e *MockImageHandler_Expecter) Upload(w interface{}, r interface{}) *MockImageHandler_Upload_Call {
- return &MockImageHandler_Upload_Call{Call: _e.mock.On("Upload", w, r)}
-}
-
-func (_c *MockImageHandler_Upload_Call) Run(run func(w http.ResponseWriter, r *http.Request)) *MockImageHandler_Upload_Call {
- _c.Call.Run(func(args mock.Arguments) {
- var arg0 http.ResponseWriter
- if args[0] != nil {
- arg0 = args[0].(http.ResponseWriter)
- }
- var arg1 *http.Request
- if args[1] != nil {
- arg1 = args[1].(*http.Request)
- }
- run(
- arg0,
- arg1,
- )
- })
- return _c
-}
-
-func (_c *MockImageHandler_Upload_Call) Return() *MockImageHandler_Upload_Call {
- _c.Call.Return()
- return _c
-}
-
-func (_c *MockImageHandler_Upload_Call) RunAndReturn(run func(w http.ResponseWriter, r *http.Request)) *MockImageHandler_Upload_Call {
- _c.Run(run)
- return _c
-}
diff --git a/apps/backend/internal/repository/v1/file.go b/apps/backend/internal/repository/v1/file.go
index e3a7da92..5357cd8f 100644
--- a/apps/backend/internal/repository/v1/file.go
+++ b/apps/backend/internal/repository/v1/file.go
@@ -1,11 +1,17 @@
package v1
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
+ "io"
+ "log"
+ "net/http"
"time"
+ "github.com/fingertips18/fingertips18.github.io/backend/internal/client"
"github.com/fingertips18/fingertips18.github.io/backend/internal/database"
"github.com/fingertips18/fingertips18.github.io/backend/internal/domain"
"github.com/fingertips18/fingertips18.github.io/backend/internal/utils"
@@ -19,19 +25,24 @@ type FileRepository interface {
Delete(ctx context.Context, id string) error
DeleteByParent(ctx context.Context, parentTable string, parentID string) error
FindByID(ctx context.Context, id string) (*domain.File, error)
+ Upload(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error)
}
type FileRepositoryConfig struct {
- DatabaseAPI database.DatabaseAPI
- FileTable string
+ DatabaseAPI database.DatabaseAPI
+ FileTable string
+ UploadthingSecretKey string
+ httpAPI client.HttpAPI
timeProvider domain.TimeProvider
}
type fileRepository struct {
- fileTable string
- databaseAPI database.DatabaseAPI
- timeProvider domain.TimeProvider
+ fileTable string
+ databaseAPI database.DatabaseAPI
+ uploadthingSecretKey string
+ httpAPI client.HttpAPI
+ timeProvider domain.TimeProvider
}
// NewFileRepository creates and returns a configured FileRepository.
@@ -42,15 +53,22 @@ type fileRepository struct {
// as the time provider. The returned value implements the
// FileRepository interface and is never nil.
func NewFileRepository(cfg FileRepositoryConfig) FileRepository {
+ httpAPI := cfg.httpAPI
+ if httpAPI == nil {
+ httpAPI = client.NewHTTPAPI(30 * time.Second)
+ }
+
timeProvider := cfg.timeProvider
if timeProvider == nil {
timeProvider = time.Now
}
return &fileRepository{
- fileTable: cfg.FileTable,
- databaseAPI: cfg.DatabaseAPI,
- timeProvider: timeProvider,
+ fileTable: cfg.FileTable,
+ databaseAPI: cfg.DatabaseAPI,
+ uploadthingSecretKey: cfg.UploadthingSecretKey,
+ httpAPI: httpAPI,
+ timeProvider: timeProvider,
}
}
@@ -376,3 +394,104 @@ func (r *fileRepository) FindByID(ctx context.Context, id string) (*domain.File,
return &file, nil
}
+
+// Upload uploads the provided UploadthingUploadRequest to the UploadThing service and
+// returns the URL of the uploaded file or an error.
+//
+// Behavior:
+// - Validates the incoming request via image.Validate() and returns an error if invalid.
+// - Applies default fallback values when not provided (ACL => "public-read",
+// ContentDisposition => "inline").
+// - Marshals the request to JSON and issues an HTTP POST to
+// "https://api.uploadthing.com/v6/uploadFiles" using the repository's httpAPI and the
+// repository's Uploadthing API key (r.uploadthingSecretKey). The HTTP request is executed
+// with the provided ctx.
+// - Treats any non-200 (OK) response as an error and includes the response status and body
+// in the returned error for diagnostics.
+// - Decodes the successful response into domain.FileUploadedResponse, validates it,
+// and returns the file metadata of the first returned file (uploadResp.Data[0]).
+// - All underlying errors are wrapped with context for easier debugging.
+//
+// Logging: the method logs the upload attempt and the resulting uploaded file URL on success.
+//
+// Return values:
+// - *domain.FileUploadedResponse: the file metadata on success, or nil on failure.
+// - error: non-nil if validation, marshaling, network, decoding, or response validation fails.
+func (r *fileRepository) Upload(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error) {
+ // Validate request structure
+ if err := file.Validate(); err != nil {
+ return nil, fmt.Errorf("failed to validate file: %w", err)
+ }
+
+ log.Println("Attempting to upload the file...")
+
+ payload := *file
+
+ // Default fallback values
+ if payload.ACL == nil {
+ acl := "public-read"
+ payload.ACL = &acl
+ }
+ if payload.ContentDisposition == nil {
+ contentDisposition := "inline"
+ payload.ContentDisposition = &contentDisposition
+ }
+
+ // Marshal request payload
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal payload: %w", err)
+ }
+
+ // Build HTTP request
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ "https://api.uploadthing.com/v6/uploadFiles",
+ bytes.NewBuffer(body),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create HTTP request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Uploadthing-Api-Key", r.uploadthingSecretKey)
+
+ // Execute request
+ resp, err := r.httpAPI.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to send HTTP request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ // Handle non-200 response
+ if resp.StatusCode != http.StatusOK {
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ log.Printf("failed to read error response body: %v", readErr)
+ }
+ return nil, fmt.Errorf(
+ "failed to upload file: status=%s message=%s",
+ resp.Status,
+ respBody,
+ )
+ }
+
+ // Decode UploadThing success response
+ var uploadResp domain.FileUploadedResponse
+ if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
+ return nil, fmt.Errorf("failed to decode uploadthing response: %w", err)
+ }
+
+ // Make sure at least one file was returned
+ if err := uploadResp.Validate(); err != nil {
+ return nil, fmt.Errorf("invalid uploadthing response: %w", err)
+ }
+
+ // Extract file URL
+ data := uploadResp.Data[0]
+
+ log.Println("File uploaded successfully:", data.FileName)
+
+ return &data, nil
+}
diff --git a/apps/backend/internal/repository/v1/image_test.go b/apps/backend/internal/repository/v1/file_test.go
similarity index 84%
rename from apps/backend/internal/repository/v1/image_test.go
rename to apps/backend/internal/repository/v1/file_test.go
index a69f1a89..669e8fc4 100644
--- a/apps/backend/internal/repository/v1/image_test.go
+++ b/apps/backend/internal/repository/v1/file_test.go
@@ -15,34 +15,73 @@ import (
"github.com/stretchr/testify/mock"
)
-type imageRepositoryTestFixture struct {
- t *testing.T
- mockHttpAPI *client.MockHttpAPI
- imageRepository imageRepository
+type fileRepositoryTestFixture struct {
+ t *testing.T
+ mockHttpAPI *client.MockHttpAPI
+ fileRepository fileRepository
}
-func newImageRepositoryTestFixture(t *testing.T) *imageRepositoryTestFixture {
+func newFileRepositoryTestFixture(t *testing.T) *fileRepositoryTestFixture {
mockHttpAPI := client.NewMockHttpAPI(t)
- imageRepository := &imageRepository{
+ fileRepository := &fileRepository{
uploadthingSecretKey: "test_token_xxx",
httpAPI: mockHttpAPI,
}
- return &imageRepositoryTestFixture{
- t: t,
- mockHttpAPI: mockHttpAPI,
- imageRepository: *imageRepository,
+ return &fileRepositoryTestFixture{
+ t: t,
+ mockHttpAPI: mockHttpAPI,
+ fileRepository: *fileRepository,
}
}
-func TestImageRepository_Upload(t *testing.T) {
+func TestNewFileRepository(t *testing.T) {
+ t.Run("Creates repository with provided httpAPI", func(t *testing.T) {
+ // Arrange
+ mockHttpAPI := client.NewMockHttpAPI(t)
+ cfg := FileRepositoryConfig{
+ UploadthingSecretKey: "test_token",
+ httpAPI: mockHttpAPI,
+ }
+
+ // Act
+ repo := NewFileRepository(cfg)
+
+ // Assert
+ assert.NotNil(t, repo)
+ concreteRepo, ok := repo.(*fileRepository)
+ assert.True(t, ok)
+ assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey)
+ assert.Equal(t, mockHttpAPI, concreteRepo.httpAPI)
+ })
+
+ t.Run("Creates repository with default httpAPI when nil", func(t *testing.T) {
+ // Arrange
+ cfg := FileRepositoryConfig{
+ UploadthingSecretKey: "test_token",
+ httpAPI: nil,
+ }
+
+ // Act
+ repo := NewFileRepository(cfg)
+
+ // Assert
+ assert.NotNil(t, repo)
+ concreteRepo, ok := repo.(*fileRepository)
+ assert.True(t, ok)
+ assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey)
+ assert.NotNil(t, concreteRepo.httpAPI)
+ })
+}
+
+func TestFileRepository_Upload(t *testing.T) {
httpErr := errors.New("http error")
- missingFilesErr := errors.New("failed to validate image: files missing")
- missingNameErr := errors.New("failed to validate image: file[0]: name missing")
- invalidSizeErr := errors.New("failed to validate image: file[0]: size invalid")
- missingTypeErr := errors.New("failed to validate image: file[0]: type missing")
- invalidACLErr := errors.New("failed to validate image: acl must be 'public-read' or 'private'")
- invalidContentDispositionErr := errors.New("failed to validate image: contentDisposition must be 'inline' or 'attachment'")
+ missingFilesErr := errors.New("failed to validate file: files missing")
+ missingNameErr := errors.New("failed to validate file: file[0]: name missing")
+ invalidSizeErr := errors.New("failed to validate file: file[0]: size invalid")
+ missingTypeErr := errors.New("failed to validate file: file[0]: type missing")
+ invalidACLErr := errors.New("failed to validate file: acl must be 'public-read' or 'private'")
+ invalidContentDispositionErr := errors.New("failed to validate file: contentDisposition must be 'inline' or 'attachment'")
customID := "custom-123"
acl := "public-read"
@@ -51,8 +90,8 @@ func TestImageRepository_Upload(t *testing.T) {
contentDisposition := "inline"
invalidContentDisposition := "invalid-disposition"
- validPayload := &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ validPayload := &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "test-image.jpg",
Size: 1024,
@@ -83,12 +122,12 @@ func TestImageRepository_Upload(t *testing.T) {
}`
type Given struct {
- payload *domain.ImageUploadRequest
+ payload *domain.FileUploadRequest
mockUpload func(m *client.MockHttpAPI)
}
type Expected struct {
- data *domain.ImageUploadFile
+ data *domain.FileUploaded
err error
}
@@ -109,7 +148,7 @@ func TestImageRepository_Upload(t *testing.T) {
},
},
expected: Expected{
- data: &domain.ImageUploadFile{
+ data: &domain.FileUploaded{
Key: "abc123",
URL: "https://utfs.io/f/abc123",
FileName: "test-image.jpg",
@@ -125,8 +164,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Successful upload with defaults applied": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "test.png",
Size: 2048,
@@ -144,7 +183,7 @@ func TestImageRepository_Upload(t *testing.T) {
},
},
expected: Expected{
- data: &domain.ImageUploadFile{
+ data: &domain.FileUploaded{
Key: "abc123",
URL: "https://utfs.io/f/abc123",
FileName: "test-image.jpg",
@@ -160,8 +199,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Successful upload with private ACL": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "private.jpg",
Size: 512,
@@ -180,7 +219,7 @@ func TestImageRepository_Upload(t *testing.T) {
},
},
expected: Expected{
- data: &domain.ImageUploadFile{
+ data: &domain.FileUploaded{
Key: "abc123",
URL: "https://utfs.io/f/abc123",
FileName: "test-image.jpg",
@@ -223,7 +262,7 @@ func TestImageRepository_Upload(t *testing.T) {
},
expected: Expected{
data: nil,
- err: errors.New("failed to upload image: status=400 Bad Request message={\"error\": \"invalid request\"}"),
+ err: errors.New("failed to upload file: status=400 Bad Request message={\"error\": \"invalid request\"}"),
},
},
"Invalid JSON response": {
@@ -365,8 +404,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Validation error: missing files": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{},
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{},
},
mockUpload: nil,
},
@@ -377,8 +416,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Validation error: missing file name": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "",
Size: 1024,
@@ -395,8 +434,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Validation error: invalid file size": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "test.jpg",
Size: 0,
@@ -413,8 +452,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Validation error: missing file type": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "test.jpg",
Size: 1024,
@@ -431,8 +470,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Validation error: invalid ACL": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "test.jpg",
Size: 1024,
@@ -450,8 +489,8 @@ func TestImageRepository_Upload(t *testing.T) {
},
"Validation error: invalid content disposition": {
given: Given{
- payload: &domain.ImageUploadRequest{
- Files: []domain.ImageFile{
+ payload: &domain.FileUploadRequest{
+ Files: []domain.FileUpload{
{
Name: "test.jpg",
Size: 1024,
@@ -472,13 +511,13 @@ func TestImageRepository_Upload(t *testing.T) {
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
// Arrange
- fixture := newImageRepositoryTestFixture(t)
+ fixture := newFileRepositoryTestFixture(t)
if tc.given.mockUpload != nil {
tc.given.mockUpload(fixture.mockHttpAPI)
}
// Act
- data, err := fixture.imageRepository.Upload(context.Background(), tc.given.payload)
+ data, err := fixture.fileRepository.Upload(context.Background(), tc.given.payload)
// Assert
if tc.expected.err != nil {
@@ -496,42 +535,3 @@ func TestImageRepository_Upload(t *testing.T) {
})
}
}
-
-func TestNewImageRepository(t *testing.T) {
- t.Run("Creates repository with provided httpAPI", func(t *testing.T) {
- // Arrange
- mockHttpAPI := client.NewMockHttpAPI(t)
- cfg := ImageRepositoryConfig{
- UploadthingSecretKey: "test_token",
- httpAPI: mockHttpAPI,
- }
-
- // Act
- repo := NewImageRepository(cfg)
-
- // Assert
- assert.NotNil(t, repo)
- concreteRepo, ok := repo.(*imageRepository)
- assert.True(t, ok)
- assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey)
- assert.Equal(t, mockHttpAPI, concreteRepo.httpAPI)
- })
-
- t.Run("Creates repository with default httpAPI when nil", func(t *testing.T) {
- // Arrange
- cfg := ImageRepositoryConfig{
- UploadthingSecretKey: "test_token",
- httpAPI: nil,
- }
-
- // Act
- repo := NewImageRepository(cfg)
-
- // Assert
- assert.NotNil(t, repo)
- concreteRepo, ok := repo.(*imageRepository)
- assert.True(t, ok)
- assert.Equal(t, "test_token", concreteRepo.uploadthingSecretKey)
- assert.NotNil(t, concreteRepo.httpAPI)
- })
-}
diff --git a/apps/backend/internal/repository/v1/image.go b/apps/backend/internal/repository/v1/image.go
deleted file mode 100644
index 503f3882..00000000
--- a/apps/backend/internal/repository/v1/image.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package v1
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "log"
- "net/http"
- "time"
-
- "github.com/fingertips18/fingertips18.github.io/backend/internal/client"
- "github.com/fingertips18/fingertips18.github.io/backend/internal/domain"
-)
-
-type ImageRepository interface {
- Upload(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error)
-}
-
-type ImageRepositoryConfig struct {
- UploadthingSecretKey string
-
- httpAPI client.HttpAPI
-}
-
-type imageRepository struct {
- uploadthingSecretKey string
- httpAPI client.HttpAPI
-}
-
-// NewImageRepository creates and returns an ImageRepository configured using the
-// provided ImageRepositoryConfig. If cfg.httpAPI is nil, a default HTTP API
-// client with a 30-second timeout will be created. The returned repository will
-// use cfg.UploadthingSecretKey for authenticated requests and the configured
-// httpAPI for performing image-related operations.
-func NewImageRepository(cfg ImageRepositoryConfig) ImageRepository {
- httpAPI := cfg.httpAPI
- if httpAPI == nil {
- httpAPI = client.NewHTTPAPI(30 * time.Second)
- }
-
- return &imageRepository{
- uploadthingSecretKey: cfg.UploadthingSecretKey,
- httpAPI: httpAPI,
- }
-}
-
-// Upload uploads the provided UploadthingUploadRequest to the UploadThing service and
-// returns the URL of the uploaded file or an error.
-//
-// Behavior:
-// - Validates the incoming request via image.Validate() and returns an error if invalid.
-// - Applies default fallback values when not provided (ACL => "public-read",
-// ContentDisposition => "inline").
-// - Marshals the request to JSON and issues an HTTP POST to
-// "https://api.uploadthing.com/v6/uploadFiles" using the repository's httpAPI and the
-// repository's Uploadthing API key (r.uploadthingSecretKey). The HTTP request is executed
-// with the provided ctx.
-// - Treats any non-200 (OK) response as an error and includes the response status and body
-// in the returned error for diagnostics.
-// - Decodes the successful response into domain.ImageUploadResponse, validates it,
-// and returns the file metadata of the first returned file (uploadResp.Data[0]).
-// - All underlying errors are wrapped with context for easier debugging.
-//
-// Logging: the method logs the upload attempt and the resulting uploaded file URL on success.
-//
-// Return values:
-// - *domain.ImageUploadFile: the file metadata on success, or nil on failure.
-// - error: non-nil if validation, marshaling, network, decoding, or response validation fails.
-func (r *imageRepository) Upload(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error) {
- // Validate request structure
- if err := image.Validate(); err != nil {
- return nil, fmt.Errorf("failed to validate image: %w", err)
- }
-
- log.Println("Attempting to upload the image...")
-
- payload := *image
-
- // Default fallback values
- if payload.ACL == nil {
- acl := "public-read"
- payload.ACL = &acl
- }
- if payload.ContentDisposition == nil {
- contentDisposition := "inline"
- payload.ContentDisposition = &contentDisposition
- }
-
- // Marshal request payload
- body, err := json.Marshal(payload)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal payload: %w", err)
- }
-
- // Build HTTP request
- req, err := http.NewRequestWithContext(
- ctx,
- http.MethodPost,
- "https://api.uploadthing.com/v6/uploadFiles",
- bytes.NewBuffer(body),
- )
- if err != nil {
- return nil, fmt.Errorf("failed to create HTTP request: %w", err)
- }
-
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("X-Uploadthing-Api-Key", r.uploadthingSecretKey)
-
- // Execute request
- resp, err := r.httpAPI.Do(req)
- if err != nil {
- return nil, fmt.Errorf("failed to send HTTP request: %w", err)
- }
- defer resp.Body.Close()
-
- // Handle non-200 response
- if resp.StatusCode != http.StatusOK {
- respBody, readErr := io.ReadAll(resp.Body)
- if readErr != nil {
- log.Printf("failed to read error response body: %v", readErr)
- }
- return nil, fmt.Errorf(
- "failed to upload image: status=%s message=%s",
- resp.Status,
- respBody,
- )
- }
-
- // Decode UploadThing success response
- var uploadResp domain.ImageUploadResponse
- if err := json.NewDecoder(resp.Body).Decode(&uploadResp); err != nil {
- return nil, fmt.Errorf("failed to decode uploadthing response: %w", err)
- }
-
- // Make sure at least one file was returned
- if err := uploadResp.Validate(); err != nil {
- return nil, fmt.Errorf("invalid uploadthing response: %w", err)
- }
-
- // Extract file URL
- data := uploadResp.Data[0]
-
- log.Println("Image uploaded successfully:", data.FileName)
-
- return &data, nil
-}
diff --git a/apps/backend/internal/repository/v1/mocks/file_repository.go b/apps/backend/internal/repository/v1/mocks/file_repository.go
index c6d6a70f..7210cabc 100644
--- a/apps/backend/internal/repository/v1/mocks/file_repository.go
+++ b/apps/backend/internal/repository/v1/mocks/file_repository.go
@@ -439,3 +439,71 @@ func (_c *MockFileRepository_Update_Call) RunAndReturn(run func(ctx context.Cont
_c.Call.Return(run)
return _c
}
+
+// Upload provides a mock function for the type MockFileRepository
+func (_mock *MockFileRepository) Upload(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error) {
+ ret := _mock.Called(ctx, file)
+
+ if len(ret) == 0 {
+ panic("no return value specified for Upload")
+ }
+
+ var r0 *domain.FileUploaded
+ var r1 error
+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.FileUploadRequest) (*domain.FileUploaded, error)); ok {
+ return returnFunc(ctx, file)
+ }
+ if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.FileUploadRequest) *domain.FileUploaded); ok {
+ r0 = returnFunc(ctx, file)
+ } else {
+ if ret.Get(0) != nil {
+ r0 = ret.Get(0).(*domain.FileUploaded)
+ }
+ }
+ if returnFunc, ok := ret.Get(1).(func(context.Context, *domain.FileUploadRequest) error); ok {
+ r1 = returnFunc(ctx, file)
+ } else {
+ r1 = ret.Error(1)
+ }
+ return r0, r1
+}
+
+// MockFileRepository_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload'
+type MockFileRepository_Upload_Call struct {
+ *mock.Call
+}
+
+// Upload is a helper method to define mock.On call
+// - ctx context.Context
+// - file *domain.FileUploadRequest
+func (_e *MockFileRepository_Expecter) Upload(ctx interface{}, file interface{}) *MockFileRepository_Upload_Call {
+ return &MockFileRepository_Upload_Call{Call: _e.mock.On("Upload", ctx, file)}
+}
+
+func (_c *MockFileRepository_Upload_Call) Run(run func(ctx context.Context, file *domain.FileUploadRequest)) *MockFileRepository_Upload_Call {
+ _c.Call.Run(func(args mock.Arguments) {
+ var arg0 context.Context
+ if args[0] != nil {
+ arg0 = args[0].(context.Context)
+ }
+ var arg1 *domain.FileUploadRequest
+ if args[1] != nil {
+ arg1 = args[1].(*domain.FileUploadRequest)
+ }
+ run(
+ arg0,
+ arg1,
+ )
+ })
+ return _c
+}
+
+func (_c *MockFileRepository_Upload_Call) Return(fileUploaded *domain.FileUploaded, err error) *MockFileRepository_Upload_Call {
+ _c.Call.Return(fileUploaded, err)
+ return _c
+}
+
+func (_c *MockFileRepository_Upload_Call) RunAndReturn(run func(ctx context.Context, file *domain.FileUploadRequest) (*domain.FileUploaded, error)) *MockFileRepository_Upload_Call {
+ _c.Call.Return(run)
+ return _c
+}
diff --git a/apps/backend/internal/repository/v1/mocks/image_repository.go b/apps/backend/internal/repository/v1/mocks/image_repository.go
deleted file mode 100644
index 2447986f..00000000
--- a/apps/backend/internal/repository/v1/mocks/image_repository.go
+++ /dev/null
@@ -1,107 +0,0 @@
-// Code generated by mockery; DO NOT EDIT.
-// github.com/vektra/mockery
-// template: testify
-
-package v1
-
-import (
- "context"
-
- "github.com/fingertips18/fingertips18.github.io/backend/internal/domain"
- mock "github.com/stretchr/testify/mock"
-)
-
-// NewMockImageRepository creates a new instance of MockImageRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
-// The first argument is typically a *testing.T value.
-func NewMockImageRepository(t interface {
- mock.TestingT
- Cleanup(func())
-}) *MockImageRepository {
- mock := &MockImageRepository{}
- mock.Mock.Test(t)
-
- t.Cleanup(func() { mock.AssertExpectations(t) })
-
- return mock
-}
-
-// MockImageRepository is an autogenerated mock type for the ImageRepository type
-type MockImageRepository struct {
- mock.Mock
-}
-
-type MockImageRepository_Expecter struct {
- mock *mock.Mock
-}
-
-func (_m *MockImageRepository) EXPECT() *MockImageRepository_Expecter {
- return &MockImageRepository_Expecter{mock: &_m.Mock}
-}
-
-// Upload provides a mock function for the type MockImageRepository
-func (_mock *MockImageRepository) Upload(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error) {
- ret := _mock.Called(ctx, image)
-
- if len(ret) == 0 {
- panic("no return value specified for Upload")
- }
-
- var r0 *domain.ImageUploadFile
- var r1 error
- if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.ImageUploadRequest) (*domain.ImageUploadFile, error)); ok {
- return returnFunc(ctx, image)
- }
- if returnFunc, ok := ret.Get(0).(func(context.Context, *domain.ImageUploadRequest) *domain.ImageUploadFile); ok {
- r0 = returnFunc(ctx, image)
- } else {
- if ret.Get(0) != nil {
- r0 = ret.Get(0).(*domain.ImageUploadFile)
- }
- }
- if returnFunc, ok := ret.Get(1).(func(context.Context, *domain.ImageUploadRequest) error); ok {
- r1 = returnFunc(ctx, image)
- } else {
- r1 = ret.Error(1)
- }
- return r0, r1
-}
-
-// MockImageRepository_Upload_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Upload'
-type MockImageRepository_Upload_Call struct {
- *mock.Call
-}
-
-// Upload is a helper method to define mock.On call
-// - ctx context.Context
-// - image *domain.ImageUploadRequest
-func (_e *MockImageRepository_Expecter) Upload(ctx interface{}, image interface{}) *MockImageRepository_Upload_Call {
- return &MockImageRepository_Upload_Call{Call: _e.mock.On("Upload", ctx, image)}
-}
-
-func (_c *MockImageRepository_Upload_Call) Run(run func(ctx context.Context, image *domain.ImageUploadRequest)) *MockImageRepository_Upload_Call {
- _c.Call.Run(func(args mock.Arguments) {
- var arg0 context.Context
- if args[0] != nil {
- arg0 = args[0].(context.Context)
- }
- var arg1 *domain.ImageUploadRequest
- if args[1] != nil {
- arg1 = args[1].(*domain.ImageUploadRequest)
- }
- run(
- arg0,
- arg1,
- )
- })
- return _c
-}
-
-func (_c *MockImageRepository_Upload_Call) Return(imageUploadFile *domain.ImageUploadFile, err error) *MockImageRepository_Upload_Call {
- _c.Call.Return(imageUploadFile, err)
- return _c
-}
-
-func (_c *MockImageRepository_Upload_Call) RunAndReturn(run func(ctx context.Context, image *domain.ImageUploadRequest) (*domain.ImageUploadFile, error)) *MockImageRepository_Upload_Call {
- _c.Call.Return(run)
- return _c
-}
diff --git a/apps/backend/internal/server/server.go b/apps/backend/internal/server/server.go
index 7f80fb74..1072f1fd 100644
--- a/apps/backend/internal/server/server.go
+++ b/apps/backend/internal/server/server.go
@@ -131,15 +131,10 @@ func createHandlers(cfg Config) []handlerConfig {
},
)
- imageHandler := v1.NewImageServiceHandler(
- v1.ImageServiceConfig{
- UploadthingSecretKey: cfg.UploadthingSecretKey,
- },
- )
-
fileHandler := v1.NewFileServiceHandler(
v1.FileServiceConfig{
- DatabaseAPI: cfg.DatabaseAPI,
+ DatabaseAPI: cfg.DatabaseAPI,
+ UploadthingSecretKey: cfg.UploadthingSecretKey,
},
)
@@ -164,10 +159,6 @@ func createHandlers(cfg Config) []handlerConfig {
paths: []string{"/skill", "/skill/", "/skills", "/skills/"},
handler: skillHandler,
},
- {
- paths: []string{"/image", "/image/"},
- handler: imageHandler,
- },
{
paths: []string{"/file", "/file/", "/files", "/files/"},
handler: fileHandler,