554 lines
16 KiB
TypeScript
554 lines
16 KiB
TypeScript
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 { autoEnhancePrompt } from '@/middleware/promptEnhancement';
|
|
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;
|
|
};
|
|
|
|
/**
|
|
* Create a new image generation from a text prompt
|
|
*
|
|
* Generates AI-powered images using Gemini Flash Image model with support for:
|
|
* - Text prompts with optional auto-enhancement
|
|
* - Reference images for style/context
|
|
* - Flow association and flow-scoped aliases
|
|
* - Project-scoped aliases for direct access
|
|
* - Custom metadata storage
|
|
*
|
|
* @route POST /api/v1/generations
|
|
* @authentication Project Key required
|
|
* @rateLimit 100 requests per hour per API key
|
|
*
|
|
* @param {CreateGenerationRequest} req.body - Generation parameters
|
|
* @param {string} req.body.prompt - Text description of desired image (required)
|
|
* @param {string[]} [req.body.referenceImages] - Array of aliases to use as references
|
|
* @param {string} [req.body.aspectRatio='1:1'] - Aspect ratio (1:1, 16:9, 3:2, 9:16)
|
|
* @param {string} [req.body.flowId] - Associate with existing flow
|
|
* @param {string} [req.body.alias] - Project-scoped alias (@custom-name)
|
|
* @param {string} [req.body.flowAlias] - Flow-scoped alias (requires flowId)
|
|
* @param {boolean} [req.body.autoEnhance=true] - Enable prompt enhancement
|
|
* @param {object} [req.body.meta] - Custom metadata
|
|
*
|
|
* @returns {CreateGenerationResponse} 201 - Generation created with status
|
|
* @returns {object} 400 - Invalid request parameters
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
* @returns {object} 429 - Rate limit exceeded
|
|
*
|
|
* @throws {Error} VALIDATION_ERROR - Missing or invalid prompt
|
|
* @throws {Error} ALIAS_CONFLICT - Alias already exists
|
|
* @throws {Error} FLOW_NOT_FOUND - Flow ID does not exist
|
|
* @throws {Error} IMAGE_NOT_FOUND - Reference image alias not found
|
|
*
|
|
* @example
|
|
* // Basic generation
|
|
* POST /api/v1/generations
|
|
* {
|
|
* "prompt": "A serene mountain landscape at sunset",
|
|
* "aspectRatio": "16:9"
|
|
* }
|
|
*
|
|
* @example
|
|
* // With reference images and alias
|
|
* POST /api/v1/generations
|
|
* {
|
|
* "prompt": "Product photo in this style",
|
|
* "referenceImages": ["@brand-style", "@product-template"],
|
|
* "alias": "@hero-image",
|
|
* "autoEnhance": true
|
|
* }
|
|
*/
|
|
generationsRouter.post(
|
|
'/',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
rateLimitByApiKey,
|
|
autoEnhancePrompt,
|
|
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
|
const service = getGenerationService();
|
|
|
|
// Extract original prompt from middleware property if enhancement was attempted
|
|
// Otherwise fall back to request body
|
|
const prompt = req.originalPrompt || req.body.prompt;
|
|
|
|
const {
|
|
referenceImages,
|
|
aspectRatio,
|
|
flowId,
|
|
alias,
|
|
flowAlias,
|
|
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 organizationSlug = req.apiKey.organizationSlug || process.env['DEFAULT_ORG_SLUG'] || 'default';
|
|
const projectSlug = req.apiKey.projectSlug || process.env['DEFAULT_PROJECT_SLUG'] || 'main';
|
|
|
|
const generation = await service.create({
|
|
projectId,
|
|
apiKeyId,
|
|
organizationSlug,
|
|
projectSlug,
|
|
prompt,
|
|
referenceImages,
|
|
aspectRatio,
|
|
flowId,
|
|
alias,
|
|
flowAlias,
|
|
autoEnhance,
|
|
enhancedPrompt: req.enhancedPrompt,
|
|
meta,
|
|
requestId: req.requestId,
|
|
});
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
data: toGenerationResponse(generation),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* List all generations for the project with filtering and pagination
|
|
*
|
|
* Retrieves generations with support for:
|
|
* - Flow-based filtering
|
|
* - Status filtering (pending, processing, success, failed)
|
|
* - Pagination with configurable limit and offset
|
|
* - Optional inclusion of soft-deleted generations
|
|
*
|
|
* @route GET /api/v1/generations
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} [req.query.flowId] - Filter by flow ID
|
|
* @param {string} [req.query.status] - Filter by status (pending|processing|success|failed)
|
|
* @param {number} [req.query.limit=20] - Results per page (max 100)
|
|
* @param {number} [req.query.offset=0] - Number of results to skip
|
|
* @param {boolean} [req.query.includeDeleted=false] - Include soft-deleted generations
|
|
*
|
|
* @returns {ListGenerationsResponse} 200 - Paginated list of generations
|
|
* @returns {object} 400 - Invalid pagination parameters
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @example
|
|
* // List recent generations
|
|
* GET /api/v1/generations?limit=10&offset=0
|
|
*
|
|
* @example
|
|
* // Filter by flow and status
|
|
* GET /api/v1/generations?flowId=abc-123&status=success&limit=50
|
|
*/
|
|
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 a single generation by ID with full details
|
|
*
|
|
* Retrieves complete generation information including:
|
|
* - Generation status and metadata
|
|
* - Output image details (URL, dimensions, etc.)
|
|
* - Reference images used
|
|
* - Flow association
|
|
* - Timestamps and audit trail
|
|
*
|
|
* @route GET /api/v1/generations/:id
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} req.params.id - Generation ID (UUID)
|
|
*
|
|
* @returns {GetGenerationResponse} 200 - Complete generation details
|
|
* @returns {object} 404 - Generation not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
|
*
|
|
* @example
|
|
* GET /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
|
*/
|
|
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),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Update generation parameters with automatic regeneration
|
|
*
|
|
* Updates generation settings with intelligent regeneration behavior:
|
|
* - Changing prompt or aspectRatio triggers automatic regeneration
|
|
* - Changing flowId or meta updates metadata only (no regeneration)
|
|
* - Regeneration replaces existing output image (same ID and URLs)
|
|
* - All changes preserve generation history and IDs
|
|
*
|
|
* @route PUT /api/v1/generations/:id
|
|
* @authentication Project Key required
|
|
* @rateLimit 100 requests per hour per API key
|
|
*
|
|
* @param {string} req.params.id - Generation ID (UUID)
|
|
* @param {UpdateGenerationRequest} req.body - Update parameters
|
|
* @param {string} [req.body.prompt] - New prompt (triggers regeneration)
|
|
* @param {string} [req.body.aspectRatio] - New aspect ratio (triggers regeneration)
|
|
* @param {string|null} [req.body.flowId] - Change flow association (null to detach)
|
|
* @param {object} [req.body.meta] - Update custom metadata
|
|
*
|
|
* @returns {GetGenerationResponse} 200 - Updated generation with new output
|
|
* @returns {object} 404 - Generation not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
* @returns {object} 429 - Rate limit exceeded
|
|
*
|
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
|
* @throws {Error} FLOW_NOT_FOUND - New flow ID does not exist
|
|
*
|
|
* @example
|
|
* // Update prompt (triggers regeneration)
|
|
* PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
|
* {
|
|
* "prompt": "Updated: A mountain landscape with vibrant colors"
|
|
* }
|
|
*
|
|
* @example
|
|
* // Change flow association (no regeneration)
|
|
* PUT /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
|
* {
|
|
* "flowId": "new-flow-id-123"
|
|
* }
|
|
*/
|
|
generationsRouter.put(
|
|
'/:id',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
rateLimitByApiKey,
|
|
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
|
const service = getGenerationService();
|
|
const { id } = req.params;
|
|
const { prompt, aspectRatio, flowId, meta } = 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 updated = await service.update(id, {
|
|
prompt,
|
|
aspectRatio,
|
|
flowId,
|
|
meta,
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
data: toGenerationResponse(updated),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Regenerate existing generation with exact same parameters
|
|
*
|
|
* Creates a new image using the original generation parameters:
|
|
* - Uses exact same prompt, aspect ratio, and reference images
|
|
* - Works regardless of current status (success, failed, pending)
|
|
* - Replaces existing output image (preserves ID and URLs)
|
|
* - No parameter modifications allowed (use PUT for changes)
|
|
* - Useful for refreshing stale images or recovering from failures
|
|
*
|
|
* @route POST /api/v1/generations/:id/regenerate
|
|
* @authentication Project Key required
|
|
* @rateLimit 100 requests per hour per API key
|
|
*
|
|
* @param {string} req.params.id - Generation ID (UUID)
|
|
*
|
|
* @returns {GetGenerationResponse} 200 - Regenerated generation with new output
|
|
* @returns {object} 404 - Generation not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
* @returns {object} 429 - Rate limit exceeded
|
|
*
|
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
|
*
|
|
* @example
|
|
* POST /api/v1/generations/550e8400-e29b-41d4-a716-446655440000/regenerate
|
|
*/
|
|
generationsRouter.post(
|
|
'/:id/regenerate',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
rateLimitByApiKey,
|
|
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
|
const service = getGenerationService();
|
|
const { id } = req.params;
|
|
|
|
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 regenerated = await service.regenerate(id);
|
|
|
|
res.json({
|
|
success: true,
|
|
data: toGenerationResponse(regenerated),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Retry a failed generation (legacy endpoint)
|
|
*
|
|
* @deprecated Use POST /api/v1/generations/:id/regenerate instead
|
|
*
|
|
* This endpoint is maintained for backward compatibility and delegates
|
|
* to the regenerate endpoint. New integrations should use /regenerate.
|
|
*
|
|
* @route POST /api/v1/generations/:id/retry
|
|
* @authentication Project Key required
|
|
* @rateLimit 100 requests per hour per API key
|
|
*
|
|
* @param {string} req.params.id - Generation ID (UUID)
|
|
*
|
|
* @returns {CreateGenerationResponse} 201 - Regenerated generation
|
|
* @returns {object} 404 - Generation not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
* @returns {object} 429 - Rate limit exceeded
|
|
*
|
|
* @see POST /api/v1/generations/:id/regenerate - Preferred endpoint
|
|
*/
|
|
generationsRouter.post(
|
|
'/:id/retry',
|
|
validateApiKey,
|
|
requireProjectKey,
|
|
rateLimitByApiKey,
|
|
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
|
const service = getGenerationService();
|
|
const { id } = req.params;
|
|
|
|
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 regenerated = await service.regenerate(id);
|
|
|
|
res.status(201).json({
|
|
success: true,
|
|
data: toGenerationResponse(regenerated),
|
|
});
|
|
})
|
|
);
|
|
|
|
/**
|
|
* Delete a generation and conditionally its output image (Section 7.2)
|
|
*
|
|
* Performs deletion with alias protection:
|
|
* - Hard delete generation record (permanently removed from database)
|
|
* - If output image has NO project alias: hard delete image with MinIO cleanup
|
|
* - If output image HAS project alias: keep image, set generationId=NULL
|
|
*
|
|
* Rationale: Images with aliases are used as standalone assets and should be preserved.
|
|
* Images without aliases were created only for this generation and can be deleted together.
|
|
*
|
|
* @route DELETE /api/v1/generations/:id
|
|
* @authentication Project Key required
|
|
*
|
|
* @param {string} req.params.id - Generation ID (UUID)
|
|
*
|
|
* @returns {object} 200 - Deletion confirmation with generation ID
|
|
* @returns {object} 404 - Generation not found or access denied
|
|
* @returns {object} 401 - Missing or invalid API key
|
|
*
|
|
* @throws {Error} GENERATION_NOT_FOUND - Generation does not exist
|
|
*
|
|
* @example
|
|
* DELETE /api/v1/generations/550e8400-e29b-41d4-a716-446655440000
|
|
*
|
|
* Response:
|
|
* {
|
|
* "success": true,
|
|
* "data": { "id": "550e8400-e29b-41d4-a716-446655440000" }
|
|
* }
|
|
*/
|
|
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 },
|
|
});
|
|
})
|
|
);
|