feature/api-development #1

Merged
usulpro merged 47 commits from feature/api-development into main 2025-11-29 23:03:01 +07:00
7 changed files with 819 additions and 4 deletions
Showing only changes of commit 2c67dad9c2 - Show all commits

View File

@ -7,6 +7,7 @@ import { imagesRouter } from './routes/images';
import { uploadRouter } from './routes/upload';
import bootstrapRoutes from './routes/bootstrap';
import adminKeysRoutes from './routes/admin/keys';
import { v1Router } from './routes/v1';
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
// Load environment variables
@ -116,7 +117,10 @@ export const createApp = (): Application => {
// Admin routes (require master key)
app.use('/api/admin/keys', adminKeysRoutes);
// Protected API routes (require valid API key)
// API v1 routes (versioned, require valid API key)
app.use('/api/v1', v1Router);
// Protected API routes (require valid API key) - Legacy
app.use('/api', textToImageRouter);
app.use('/api', imagesRouter);
app.use('/api', uploadRouter);

View File

@ -0,0 +1,249 @@
import { Response, Router } from 'express';
import type { Router as RouterType } from 'express';
import { GenerationService } from '@/services/core';
import { asyncHandler } from '@/middleware/errorHandler';
import { validateApiKey } from '@/middleware/auth/validateApiKey';
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
import { validateAndNormalizePagination } from '@/utils/validators';
import { buildPaginatedResponse } from '@/utils/helpers';
import { toGenerationResponse } from '@/types/responses';
import type {
CreateGenerationResponse,
ListGenerationsResponse,
GetGenerationResponse,
} from '@/types/responses';
export const generationsRouter: RouterType = Router();
let generationService: GenerationService;
const getGenerationService = (): GenerationService => {
if (!generationService) {
generationService = new GenerationService();
}
return generationService;
};
/**
* POST /api/v1/generations
* Create a new image generation with optional reference images and aliases
*/
generationsRouter.post(
'/',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
const service = getGenerationService();
const {
prompt,
referenceImages,
aspectRatio,
flowId,
outputAlias,
flowAliases,
autoEnhance,
meta,
} = req.body;
if (!prompt || typeof prompt !== 'string') {
res.status(400).json({
success: false,
error: {
message: 'Prompt is required and must be a string',
code: 'VALIDATION_ERROR',
},
});
return;
}
const projectId = req.apiKey.projectId;
const apiKeyId = req.apiKey.id;
const generation = await service.create({
projectId,
apiKeyId,
prompt,
referenceImages,
aspectRatio,
flowId,
outputAlias,
flowAliases,
autoEnhance,
meta,
requestId: req.requestId,
});
res.status(201).json({
success: true,
data: toGenerationResponse(generation),
});
})
);
/**
* GET /api/v1/generations
* List generations with filters and pagination
*/
generationsRouter.get(
'/',
validateApiKey,
requireProjectKey,
asyncHandler(async (req: any, res: Response<ListGenerationsResponse>) => {
const service = getGenerationService();
const { flowId, status, 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,
status: status as 'pending' | 'processing' | 'success' | 'failed' | undefined,
deleted: includeDeleted === 'true' ? true : undefined,
},
validatedLimit,
validatedOffset
);
const responseData = result.generations.map((gen) => toGenerationResponse(gen));
res.json(
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
);
})
);
/**
* GET /api/v1/generations/:id
* Get a single generation by ID with full details
*/
generationsRouter.get(
'/:id',
validateApiKey,
requireProjectKey,
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
const service = getGenerationService();
const { id } = req.params;
const generation = await service.getByIdWithRelations(id);
if (generation.projectId !== req.apiKey.projectId) {
res.status(404).json({
success: false,
error: {
message: 'Generation not found',
code: 'GENERATION_NOT_FOUND',
},
});
return;
}
res.json({
success: true,
data: toGenerationResponse(generation),
});
})
);
/**
* POST /api/v1/generations/:id/retry
* Retry a failed generation
*/
generationsRouter.post(
'/:id/retry',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
const service = getGenerationService();
const { id } = req.params;
const { prompt, aspectRatio } = req.body;
const original = await service.getById(id);
if (!original) {
res.status(404).json({
success: false,
error: {
message: 'Generation not found',
code: 'GENERATION_NOT_FOUND',
},
});
return;
}
if (original.projectId !== req.apiKey.projectId) {
res.status(404).json({
success: false,
error: {
message: 'Generation not found',
code: 'GENERATION_NOT_FOUND',
},
});
return;
}
const newGeneration = await service.retry(id, { prompt, aspectRatio });
res.status(201).json({
success: true,
data: toGenerationResponse(newGeneration),
});
})
);
/**
* DELETE /api/v1/generations/:id
* Delete a generation and its output image
*/
generationsRouter.delete(
'/:id',
validateApiKey,
requireProjectKey,
asyncHandler(async (req: any, res: Response) => {
const service = getGenerationService();
const { id } = req.params;
const generation = await service.getById(id);
if (!generation) {
res.status(404).json({
success: false,
error: {
message: 'Generation not found',
code: 'GENERATION_NOT_FOUND',
},
});
return;
}
if (generation.projectId !== req.apiKey.projectId) {
res.status(404).json({
success: false,
error: {
message: 'Generation not found',
code: 'GENERATION_NOT_FOUND',
},
});
return;
}
await service.delete(id);
res.json({
success: true,
data: { id },
});
})
);

View File

@ -0,0 +1,8 @@
import { Router } from 'express';
import type { Router as RouterType } from 'express';
import { generationsRouter } from './generations';
export const v1Router: RouterType = Router();
// Mount v1 routes
v1Router.use('/generations', generationsRouter);

View File

@ -0,0 +1,355 @@
import { eq, desc, count } from 'drizzle-orm';
import { db } from '@/db';
import { generations, flows } from '@banatie/database';
import type {
Generation,
NewGeneration,
GenerationWithRelations,
GenerationFilters,
} from '@/types/models';
import { ImageService } from './ImageService';
import { AliasService } from './AliasService';
import { ImageGenService } from '../ImageGenService';
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
import { ERROR_MESSAGES, GENERATION_LIMITS } from '@/utils/constants';
import type { ReferenceImage } from '@/types/api';
export interface CreateGenerationParams {
projectId: string;
apiKeyId: string;
prompt: string;
referenceImages?: string[] | undefined; // Aliases to resolve
aspectRatio?: string | undefined;
flowId?: string | undefined;
outputAlias?: string | undefined;
flowAliases?: Record<string, string> | undefined;
autoEnhance?: boolean | undefined;
enhancedPrompt?: string | undefined;
meta?: Record<string, unknown> | undefined;
requestId?: string | undefined;
}
export class GenerationService {
private imageService: ImageService;
private aliasService: AliasService;
private imageGenService: ImageGenService;
constructor() {
this.imageService = new ImageService();
this.aliasService = new AliasService();
const geminiApiKey = process.env['GEMINI_API_KEY'];
if (!geminiApiKey) {
throw new Error('GEMINI_API_KEY environment variable is required');
}
this.imageGenService = new ImageGenService(geminiApiKey);
}
async create(params: CreateGenerationParams): Promise<GenerationWithRelations> {
const startTime = Date.now();
const generationRecord: NewGeneration = {
projectId: params.projectId,
flowId: params.flowId || null,
apiKeyId: params.apiKeyId,
status: 'pending',
originalPrompt: params.prompt,
enhancedPrompt: params.enhancedPrompt || null,
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
referencedImages: null,
requestId: params.requestId || null,
meta: params.meta || {},
};
const [generation] = await db
.insert(generations)
.values(generationRecord)
.returning();
if (!generation) {
throw new Error('Failed to create generation record');
}
try {
await this.updateStatus(generation.id, 'processing');
let referenceImageBuffers: ReferenceImage[] = [];
let referencedImagesMetadata: Array<{ imageId: string; alias: string }> = [];
if (params.referenceImages && params.referenceImages.length > 0) {
const resolved = await this.resolveReferenceImages(
params.referenceImages,
params.projectId,
params.flowId
);
referenceImageBuffers = resolved.buffers;
referencedImagesMetadata = resolved.metadata;
await db
.update(generations)
.set({ referencedImages: referencedImagesMetadata })
.where(eq(generations.id, generation.id));
}
const genResult = await this.imageGenService.generateImage({
prompt: params.enhancedPrompt || params.prompt,
filename: `gen_${generation.id}`,
referenceImages: referenceImageBuffers,
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
orgId: 'default',
projectId: params.projectId,
meta: params.meta || {},
});
if (!genResult.success) {
const processingTime = Date.now() - startTime;
await this.updateStatus(generation.id, 'failed', {
errorMessage: genResult.error || 'Generation failed',
processingTimeMs: processingTime,
});
throw new Error(genResult.error || 'Generation failed');
}
const storageKey = genResult.filepath!;
// TODO: Add file hash computation when we have a helper to download by storageKey
const fileHash = null;
const imageRecord = await this.imageService.create({
projectId: params.projectId,
flowId: params.flowId || null,
generationId: generation.id,
apiKeyId: params.apiKeyId,
storageKey,
storageUrl: genResult.url!,
mimeType: 'image/jpeg',
fileSize: 0, // TODO: Get actual file size from storage
fileHash,
source: 'generated',
alias: params.outputAlias || null,
meta: params.meta || {},
});
if (params.flowAliases && params.flowId) {
await this.assignFlowAliases(params.flowId, params.flowAliases, imageRecord.id);
}
if (params.flowId) {
await db
.update(flows)
.set({ updatedAt: new Date() })
.where(eq(flows.id, params.flowId));
}
const processingTime = Date.now() - startTime;
await this.updateStatus(generation.id, 'success', {
outputImageId: imageRecord.id,
processingTimeMs: processingTime,
});
return await this.getByIdWithRelations(generation.id);
} catch (error) {
const processingTime = Date.now() - startTime;
await this.updateStatus(generation.id, 'failed', {
errorMessage: error instanceof Error ? error.message : 'Unknown error',
processingTimeMs: processingTime,
});
throw error;
}
}
private async resolveReferenceImages(
aliases: string[],
projectId: string,
flowId?: string
): Promise<{
buffers: ReferenceImage[];
metadata: Array<{ imageId: string; alias: string }>;
}> {
const resolutions = await this.aliasService.resolveMultiple(aliases, projectId, flowId);
const buffers: ReferenceImage[] = [];
const metadata: Array<{ imageId: string; alias: string }> = [];
// TODO: Implement proper storage key parsing and download
// For now, we'll skip reference image buffers and just store metadata
for (const [alias, resolution] of resolutions) {
if (!resolution.image) {
throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`);
}
metadata.push({
imageId: resolution.imageId,
alias,
});
}
return { buffers, metadata };
}
private async assignFlowAliases(
flowId: string,
flowAliases: Record<string, string>,
imageId: string
): Promise<void> {
const flow = await db.query.flows.findFirst({
where: eq(flows.id, flowId),
});
if (!flow) {
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
}
const currentAliases = (flow.aliases as Record<string, string>) || {};
const updatedAliases = { ...currentAliases };
for (const [alias, value] of Object.entries(flowAliases)) {
if (value === '@output' || value === imageId) {
updatedAliases[alias] = imageId;
}
}
await db
.update(flows)
.set({ aliases: updatedAliases, updatedAt: new Date() })
.where(eq(flows.id, flowId));
}
private async updateStatus(
id: string,
status: 'pending' | 'processing' | 'success' | 'failed',
additionalUpdates?: {
errorMessage?: string;
outputImageId?: string;
processingTimeMs?: number;
}
): Promise<void> {
await db
.update(generations)
.set({
status,
...additionalUpdates,
updatedAt: new Date(),
})
.where(eq(generations.id, id));
}
async getById(id: string): Promise<Generation | null> {
const generation = await db.query.generations.findFirst({
where: eq(generations.id, id),
});
return generation || null;
}
async getByIdWithRelations(id: string): Promise<GenerationWithRelations> {
const generation = await db.query.generations.findFirst({
where: eq(generations.id, id),
with: {
outputImage: true,
flow: true,
},
});
if (!generation) {
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
}
if (generation.referencedImages && Array.isArray(generation.referencedImages)) {
const refImageIds = (generation.referencedImages as Array<{ imageId: string; alias: string }>)
.map((ref) => ref.imageId);
const refImages = await this.imageService.getMultipleByIds(refImageIds);
return {
...generation,
referenceImages: refImages,
} as GenerationWithRelations;
}
return generation as GenerationWithRelations;
}
async list(
filters: GenerationFilters,
limit: number,
offset: number
): Promise<{ generations: GenerationWithRelations[]; total: number }> {
const conditions = [
buildEqCondition(generations, 'projectId', filters.projectId),
buildEqCondition(generations, 'flowId', filters.flowId),
buildEqCondition(generations, 'status', filters.status),
];
const whereClause = buildWhereClause(conditions);
const [generationsList, countResult] = await Promise.all([
db.query.generations.findMany({
where: whereClause,
orderBy: [desc(generations.createdAt)],
limit,
offset,
with: {
outputImage: true,
flow: true,
},
}),
db
.select({ count: count() })
.from(generations)
.where(whereClause),
]);
const totalCount = countResult[0]?.count || 0;
return {
generations: generationsList as GenerationWithRelations[],
total: Number(totalCount),
};
}
async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
const original = await this.getByIdWithRelations(id);
if (original.status === 'success') {
throw new Error(ERROR_MESSAGES.GENERATION_ALREADY_SUCCEEDED);
}
if (original.retryCount >= GENERATION_LIMITS.MAX_RETRY_COUNT) {
throw new Error(ERROR_MESSAGES.MAX_RETRY_COUNT_EXCEEDED);
}
if (!original.apiKeyId) {
throw new Error('Cannot retry generation without API key');
}
const newParams: CreateGenerationParams = {
projectId: original.projectId,
apiKeyId: original.apiKeyId,
prompt: overrides?.prompt || original.originalPrompt,
aspectRatio: overrides?.aspectRatio || original.aspectRatio || undefined,
flowId: original.flowId || undefined,
enhancedPrompt: original.enhancedPrompt || undefined,
meta: original.meta as Record<string, unknown>,
};
const newGeneration = await this.create(newParams);
await db
.update(generations)
.set({ retryCount: original.retryCount + 1 })
.where(eq(generations.id, newGeneration.id));
return newGeneration;
}
async delete(id: string): Promise<void> {
const generation = await this.getById(id);
if (!generation) {
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
}
if (generation.outputImageId) {
await this.imageService.softDelete(generation.outputImageId);
}
await db.delete(generations).where(eq(generations.id, id));
}
}

View File

@ -0,0 +1,197 @@
import { eq, and, isNull, desc, count, sql } from 'drizzle-orm';
import { db } from '@/db';
import { images } from '@banatie/database';
import type { Image, NewImage, ImageFilters } from '@/types/models';
import { buildWhereClause, buildEqCondition, withoutDeleted } from '@/utils/helpers';
import { ERROR_MESSAGES } from '@/utils/constants';
import { AliasService } from './AliasService';
export class ImageService {
private aliasService: AliasService;
constructor() {
this.aliasService = new AliasService();
}
async create(data: NewImage): Promise<Image> {
const [image] = await db.insert(images).values(data).returning();
if (!image) {
throw new Error('Failed to create image record');
}
return image;
}
async getById(id: string, includeDeleted = false): Promise<Image | null> {
const image = await db.query.images.findFirst({
where: and(
eq(images.id, id),
includeDeleted ? undefined : isNull(images.deletedAt)
),
});
return image || null;
}
async getByIdOrThrow(id: string, includeDeleted = false): Promise<Image> {
const image = await this.getById(id, includeDeleted);
if (!image) {
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
}
return image;
}
async list(
filters: ImageFilters,
limit: number,
offset: number
): Promise<{ images: Image[]; total: number }> {
const conditions = [
buildEqCondition(images, 'projectId', filters.projectId),
buildEqCondition(images, 'flowId', filters.flowId),
buildEqCondition(images, 'source', filters.source),
buildEqCondition(images, 'alias', filters.alias),
withoutDeleted(images, filters.deleted),
];
const whereClause = buildWhereClause(conditions);
const [imagesList, countResult] = await Promise.all([
db.query.images.findMany({
where: whereClause,
orderBy: [desc(images.createdAt)],
limit,
offset,
}),
db
.select({ count: count() })
.from(images)
.where(whereClause),
]);
const totalCount = countResult[0]?.count || 0;
return {
images: imagesList,
total: Number(totalCount),
};
}
async update(
id: string,
updates: {
alias?: string;
focalPoint?: { x: number; y: number };
meta?: Record<string, unknown>;
}
): Promise<Image> {
const existing = await this.getByIdOrThrow(id);
if (updates.alias && updates.alias !== existing.alias) {
await this.aliasService.validateAliasForAssignment(
updates.alias,
existing.projectId,
existing.flowId || undefined
);
}
const [updated] = await db
.update(images)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(images.id, id))
.returning();
if (!updated) {
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
}
return updated;
}
async softDelete(id: string): Promise<Image> {
const [deleted] = await db
.update(images)
.set({
deletedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(images.id, id))
.returning();
if (!deleted) {
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
}
return deleted;
}
async hardDelete(id: string): Promise<void> {
await db.delete(images).where(eq(images.id, id));
}
async assignProjectAlias(imageId: string, alias: string): Promise<Image> {
const image = await this.getByIdOrThrow(imageId);
if (image.flowId) {
throw new Error('Cannot assign project alias to flow-scoped image');
}
await this.aliasService.validateAliasForAssignment(
alias,
image.projectId
);
const [updated] = await db
.update(images)
.set({
alias,
updatedAt: new Date(),
})
.where(eq(images.id, imageId))
.returning();
if (!updated) {
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
}
return updated;
}
async getByStorageKey(storageKey: string): Promise<Image | null> {
const image = await db.query.images.findFirst({
where: and(
eq(images.storageKey, storageKey),
isNull(images.deletedAt)
),
});
return image || null;
}
async getByFileHash(fileHash: string, projectId: string): Promise<Image | null> {
const image = await db.query.images.findFirst({
where: and(
eq(images.fileHash, fileHash),
eq(images.projectId, projectId),
isNull(images.deletedAt)
),
});
return image || null;
}
async getMultipleByIds(ids: string[]): Promise<Image[]> {
if (ids.length === 0) {
return [];
}
return await db.query.images.findMany({
where: and(
sql`${images.id} = ANY(${ids})`,
isNull(images.deletedAt)
),
});
}
}

View File

@ -1 +1,3 @@
export * from './AliasService';
export * from './ImageService';
export * from './GenerationService';

View File

@ -71,9 +71,9 @@ export interface ImageFilters {
// Query filters for generations
export interface GenerationFilters {
projectId: string;
flowId?: string;
status?: GenerationStatus;
deleted?: boolean;
flowId?: string | undefined;
status?: GenerationStatus | undefined;
deleted?: boolean | undefined;
}
// Query filters for flows