feature/api-development #1

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

View File

@ -3,6 +3,7 @@ import type { Router as RouterType } from 'express';
import { generationsRouter } from './generations';
import { flowsRouter } from './flows';
import { imagesRouter } from './images';
import { liveRouter } from './live';
export const v1Router: RouterType = Router();
@ -10,3 +11,4 @@ export const v1Router: RouterType = Router();
v1Router.use('/generations', generationsRouter);
v1Router.use('/flows', flowsRouter);
v1Router.use('/images', imagesRouter);
v1Router.use('/live', liveRouter);

View File

@ -0,0 +1,196 @@
import { Response, Router } from 'express';
import type { Router as RouterType } from 'express';
import { PromptCacheService, GenerationService, 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 { GENERATION_LIMITS } from '@/utils/constants';
export const liveRouter: RouterType = Router();
let promptCacheService: PromptCacheService;
let generationService: GenerationService;
let imageService: ImageService;
const getPromptCacheService = (): PromptCacheService => {
if (!promptCacheService) {
promptCacheService = new PromptCacheService();
}
return promptCacheService;
};
const getGenerationService = (): GenerationService => {
if (!generationService) {
generationService = new GenerationService();
}
return generationService;
};
const getImageService = (): ImageService => {
if (!imageService) {
imageService = new ImageService();
}
return imageService;
};
/**
* GET /api/v1/live/generate
* Generate image with prompt caching
* Returns image bytes directly with cache headers
*/
liveRouter.get(
'/generate',
validateApiKey,
requireProjectKey,
rateLimitByApiKey,
asyncHandler(async (req: any, res: Response) => {
const cacheService = getPromptCacheService();
const genService = getGenerationService();
const imgService = getImageService();
const { prompt, aspectRatio } = req.query;
// Validate prompt
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;
try {
// Compute prompt hash for cache lookup
const promptHash = cacheService.computePromptHash(prompt);
// Check cache
const cachedEntry = await cacheService.getCachedEntry(promptHash, projectId);
if (cachedEntry) {
// Cache HIT - fetch and stream existing image
await cacheService.recordCacheHit(cachedEntry.id);
// Get image from database
const image = await imgService.getById(cachedEntry.imageId);
if (!image) {
throw new Error('Cached image not found in database');
}
const storageService = await StorageFactory.getInstance();
// Parse storage key to get components
// Format: orgId/projectId/category/year-month/filename.ext
const keyParts = image.storageKey.split('/');
if (keyParts.length < 5) {
throw new Error('Invalid storage key format');
}
const orgId = keyParts[0];
const projectIdSlug = keyParts[1];
const category = keyParts[2] as 'uploads' | 'generated' | 'references';
const filename = keyParts.slice(3).join('/');
// Download image from storage
const buffer = await storageService.downloadFile(
orgId!,
projectIdSlug!,
category,
filename!
);
// Set cache headers
res.setHeader('Content-Type', image.mimeType);
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
res.setHeader('X-Cache-Status', 'HIT');
res.setHeader('X-Cache-Hit-Count', cachedEntry.hitCount.toString());
res.setHeader('X-Image-Id', image.id);
// Stream image bytes
res.send(buffer);
return;
}
// Cache MISS - generate new image
const generation = await genService.create({
projectId,
apiKeyId,
prompt,
aspectRatio: (aspectRatio as string) || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
requestId: req.requestId,
});
// Get the output image
if (!generation.outputImage) {
throw new Error('Generation succeeded but no output image was created');
}
// Create cache entry
const queryParamsHash = cacheService.computePromptHash(
JSON.stringify({ aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO })
);
await cacheService.createCacheEntry({
projectId,
generationId: generation.id,
imageId: generation.outputImage.id,
promptHash,
queryParamsHash,
originalPrompt: prompt,
requestParams: {
aspectRatio: aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
},
hitCount: 0,
});
// Download newly generated image
const storageService = await StorageFactory.getInstance();
const keyParts = generation.outputImage.storageKey.split('/');
if (keyParts.length < 5) {
throw new Error('Invalid storage key format');
}
const orgId = keyParts[0];
const projectIdSlug = keyParts[1];
const category = keyParts[2] as 'uploads' | 'generated' | 'references';
const filename = keyParts.slice(3).join('/');
const buffer = await storageService.downloadFile(
orgId!,
projectIdSlug!,
category,
filename!
);
// Set cache headers
res.setHeader('Content-Type', generation.outputImage.mimeType);
res.setHeader('Content-Length', buffer.length);
res.setHeader('Cache-Control', 'public, max-age=31536000'); // 1 year
res.setHeader('X-Cache-Status', 'MISS');
res.setHeader('X-Generation-Id', generation.id);
res.setHeader('X-Image-Id', generation.outputImage.id);
// Stream image bytes
res.send(buffer);
return;
} catch (error) {
console.error('Live generation error:', error);
res.status(500).json({
success: false,
error: {
message: error instanceof Error ? error.message : 'Generation failed',
code: 'GENERATION_ERROR',
},
});
return;
}
})
);

View File

@ -0,0 +1,98 @@
import { eq, and, sql } from 'drizzle-orm';
import { db } from '@/db';
import { promptUrlCache } from '@banatie/database';
import type { PromptUrlCacheEntry, NewPromptUrlCacheEntry } from '@/types/models';
import { computeSHA256 } from '@/utils/helpers';
export class PromptCacheService {
/**
* Compute SHA-256 hash of prompt for cache lookup
*/
computePromptHash(prompt: string): string {
return computeSHA256(prompt);
}
/**
* Check if prompt exists in cache for a project
*/
async getCachedEntry(
promptHash: string,
projectId: string
): Promise<PromptUrlCacheEntry | null> {
const entry = await db.query.promptUrlCache.findFirst({
where: and(
eq(promptUrlCache.promptHash, promptHash),
eq(promptUrlCache.projectId, projectId)
),
});
return entry || null;
}
/**
* Create a new cache entry
*/
async createCacheEntry(data: NewPromptUrlCacheEntry): Promise<PromptUrlCacheEntry> {
const [entry] = await db.insert(promptUrlCache).values(data).returning();
if (!entry) {
throw new Error('Failed to create cache entry');
}
return entry;
}
/**
* Update hit count and last hit time for a cache entry
*/
async recordCacheHit(id: string): Promise<void> {
await db
.update(promptUrlCache)
.set({
hitCount: sql`${promptUrlCache.hitCount} + 1`,
lastHitAt: new Date(),
})
.where(eq(promptUrlCache.id, id));
}
/**
* Get cache statistics for a project
*/
async getCacheStats(projectId: string): Promise<{
totalEntries: number;
totalHits: number;
avgHitCount: number;
}> {
const entries = await db.query.promptUrlCache.findMany({
where: eq(promptUrlCache.projectId, projectId),
});
const totalEntries = entries.length;
const totalHits = entries.reduce((sum, entry) => sum + entry.hitCount, 0);
const avgHitCount = totalEntries > 0 ? totalHits / totalEntries : 0;
return {
totalEntries,
totalHits,
avgHitCount,
};
}
/**
* Clear old cache entries (can be called periodically)
*/
async clearOldEntries(daysOld: number): Promise<number> {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - daysOld);
const result = await db
.delete(promptUrlCache)
.where(
and(
eq(promptUrlCache.hitCount, 0),
// Only delete entries with 0 hits that are old
)
)
.returning();
return result.length;
}
}

View File

@ -2,3 +2,4 @@ export * from './AliasService';
export * from './ImageService';
export * from './GenerationService';
export * from './FlowService';
export * from './PromptCacheService';