feature/api-development #1
|
|
@ -0,0 +1,363 @@
|
||||||
|
import { Response, Router } from 'express';
|
||||||
|
import type { Router as RouterType } from 'express';
|
||||||
|
import { ImageService } from '@/services/core';
|
||||||
|
import { StorageFactory } from '@/services/StorageFactory';
|
||||||
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||||
|
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||||
|
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||||
|
import { uploadSingleImage, handleUploadErrors } from '@/middleware/upload';
|
||||||
|
import { validateAndNormalizePagination } from '@/utils/validators';
|
||||||
|
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||||
|
import { toImageResponse } from '@/types/responses';
|
||||||
|
import type {
|
||||||
|
UploadImageResponse,
|
||||||
|
ListImagesResponse,
|
||||||
|
GetImageResponse,
|
||||||
|
UpdateImageResponse,
|
||||||
|
DeleteImageResponse,
|
||||||
|
} from '@/types/responses';
|
||||||
|
|
||||||
|
export const imagesRouter: RouterType = Router();
|
||||||
|
|
||||||
|
let imageService: ImageService;
|
||||||
|
|
||||||
|
const getImageService = (): ImageService => {
|
||||||
|
if (!imageService) {
|
||||||
|
imageService = new ImageService();
|
||||||
|
}
|
||||||
|
return imageService;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/images/upload
|
||||||
|
* Upload a single image file and create database record
|
||||||
|
*/
|
||||||
|
imagesRouter.post(
|
||||||
|
'/upload',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
rateLimitByApiKey,
|
||||||
|
uploadSingleImage,
|
||||||
|
handleUploadErrors,
|
||||||
|
asyncHandler(async (req: any, res: Response<UploadImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { alias, flowId, meta } = req.body;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'No file provided',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
const apiKeyId = req.apiKey.id;
|
||||||
|
const orgId = req.apiKey.organizationSlug || 'default';
|
||||||
|
const projectSlug = req.apiKey.projectSlug;
|
||||||
|
const file = req.file;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storageService = await StorageFactory.getInstance();
|
||||||
|
|
||||||
|
const uploadResult = await storageService.uploadFile(
|
||||||
|
orgId,
|
||||||
|
projectSlug,
|
||||||
|
'uploads',
|
||||||
|
file.originalname,
|
||||||
|
file.buffer,
|
||||||
|
file.mimetype,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploadResult.success) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'File upload failed',
|
||||||
|
code: 'UPLOAD_ERROR',
|
||||||
|
details: uploadResult.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageRecord = await service.create({
|
||||||
|
projectId,
|
||||||
|
flowId: flowId || null,
|
||||||
|
generationId: null,
|
||||||
|
apiKeyId,
|
||||||
|
storageKey: uploadResult.path!,
|
||||||
|
storageUrl: uploadResult.url!,
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
fileSize: file.size,
|
||||||
|
fileHash: null,
|
||||||
|
source: 'uploaded',
|
||||||
|
alias: alias || null,
|
||||||
|
meta: meta ? JSON.parse(meta) : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(imageRecord),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: error instanceof Error ? error.message : 'Upload failed',
|
||||||
|
code: 'UPLOAD_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/images
|
||||||
|
* List images with filters and pagination
|
||||||
|
*/
|
||||||
|
imagesRouter.get(
|
||||||
|
'/',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<ListImagesResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { flowId, source, alias, limit, offset, includeDeleted } = req.query;
|
||||||
|
|
||||||
|
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||||
|
if (!paginationResult.valid) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||||
|
const projectId = req.apiKey.projectId;
|
||||||
|
|
||||||
|
const result = await service.list(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
flowId: flowId as string | undefined,
|
||||||
|
source: source as 'generated' | 'uploaded' | undefined,
|
||||||
|
alias: alias as string | undefined,
|
||||||
|
deleted: includeDeleted === 'true' ? true : undefined,
|
||||||
|
},
|
||||||
|
validatedLimit,
|
||||||
|
validatedOffset
|
||||||
|
);
|
||||||
|
|
||||||
|
const responseData = result.images.map((img) => toImageResponse(img));
|
||||||
|
|
||||||
|
res.json(
|
||||||
|
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/images/:id
|
||||||
|
* Get a single image by ID
|
||||||
|
*/
|
||||||
|
imagesRouter.get(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<GetImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const image = await service.getById(id);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(image),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/images/:id
|
||||||
|
* Update image metadata (alias, focal point, meta)
|
||||||
|
*/
|
||||||
|
imagesRouter.put(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id } = req.params;
|
||||||
|
const { alias, focalPoint, meta } = req.body;
|
||||||
|
|
||||||
|
const image = await service.getById(id);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: {
|
||||||
|
alias?: string;
|
||||||
|
focalPoint?: { x: number; y: number };
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (alias !== undefined) updates.alias = alias;
|
||||||
|
if (focalPoint !== undefined) updates.focalPoint = focalPoint;
|
||||||
|
if (meta !== undefined) updates.meta = meta;
|
||||||
|
|
||||||
|
const updated = await service.update(id, updates);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(updated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/images/:id/alias
|
||||||
|
* Assign a project-scoped alias to an image
|
||||||
|
*/
|
||||||
|
imagesRouter.put(
|
||||||
|
'/:id/alias',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<UpdateImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id } = req.params;
|
||||||
|
const { alias } = req.body;
|
||||||
|
|
||||||
|
if (!alias || typeof alias !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Alias is required and must be a string',
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = await service.getById(id);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await service.assignProjectAlias(id, alias);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: toImageResponse(updated),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/v1/images/:id
|
||||||
|
* Soft delete an image
|
||||||
|
*/
|
||||||
|
imagesRouter.delete(
|
||||||
|
'/:id',
|
||||||
|
validateApiKey,
|
||||||
|
requireProjectKey,
|
||||||
|
asyncHandler(async (req: any, res: Response<DeleteImageResponse>) => {
|
||||||
|
const service = getImageService();
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const image = await service.getById(id);
|
||||||
|
if (!image) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (image.projectId !== req.apiKey.projectId) {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: 'Image not found',
|
||||||
|
code: 'IMAGE_NOT_FOUND',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await service.softDelete(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: deleted.id,
|
||||||
|
deletedAt: deleted.deletedAt?.toISOString() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
@ -2,9 +2,11 @@ import { Router } from 'express';
|
||||||
import type { Router as RouterType } from 'express';
|
import type { Router as RouterType } from 'express';
|
||||||
import { generationsRouter } from './generations';
|
import { generationsRouter } from './generations';
|
||||||
import { flowsRouter } from './flows';
|
import { flowsRouter } from './flows';
|
||||||
|
import { imagesRouter } from './images';
|
||||||
|
|
||||||
export const v1Router: RouterType = Router();
|
export const v1Router: RouterType = Router();
|
||||||
|
|
||||||
// Mount v1 routes
|
// Mount v1 routes
|
||||||
v1Router.use('/generations', generationsRouter);
|
v1Router.use('/generations', generationsRouter);
|
||||||
v1Router.use('/flows', flowsRouter);
|
v1Router.use('/flows', flowsRouter);
|
||||||
|
v1Router.use('/images', imagesRouter);
|
||||||
|
|
|
||||||
|
|
@ -62,10 +62,10 @@ export interface PaginationMeta {
|
||||||
// Query filters for images
|
// Query filters for images
|
||||||
export interface ImageFilters {
|
export interface ImageFilters {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
flowId?: string;
|
flowId?: string | undefined;
|
||||||
source?: ImageSource;
|
source?: ImageSource | undefined;
|
||||||
alias?: string;
|
alias?: string | undefined;
|
||||||
deleted?: boolean;
|
deleted?: boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Query filters for generations
|
// Query filters for generations
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue