feat: add text endpoint

This commit is contained in:
Oleg Proskurin 2025-09-26 22:44:05 +07:00
parent d55eba8817
commit 1c6dfc4f8b
6 changed files with 503 additions and 1 deletions

View File

@ -14,7 +14,7 @@ API key required via `GEMINI_API_KEY` environment variable (server-side configur
## Content Types ## Content Types
- **Request**: `multipart/form-data` for file uploads, `application/json` for other endpoints - **Request**: `multipart/form-data` for file uploads, `application/json` for JSON endpoints
- **Response**: `application/json` - **Response**: `application/json`
## Rate Limits ## Rate Limits
@ -60,6 +60,7 @@ Returns API metadata and configuration limits.
"GET /health": "Health check", "GET /health": "Health check",
"GET /api/info": "API information", "GET /api/info": "API information",
"POST /api/generate": "Generate images from text prompt with optional reference images", "POST /api/generate": "Generate images from text prompt with optional reference images",
"POST /api/text-to-image": "Generate images from text prompt only (JSON)",
"POST /api/enhance": "Enhance and optimize prompts for better image generation" "POST /api/enhance": "Enhance and optimize prompts for better image generation"
}, },
"limits": { "limits": {
@ -144,6 +145,92 @@ curl -X POST http://localhost:3000/api/generate \
--- ---
### Text-to-Image (JSON)
#### `POST /api/text-to-image`
Generate images from text prompts only using JSON payload. Simplified endpoint for text-only requests without file uploads.
**Content-Type:** `application/json`
**Request Body:**
```json
{
"prompt": "A beautiful sunset over mountains",
"filename": "sunset_image",
"autoEnhance": true,
"enhancementOptions": {
"imageStyle": "photorealistic",
"aspectRatio": "landscape",
"mood": "peaceful",
"lighting": "golden hour"
}
}
```
**Parameters:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `prompt` | string | Yes | Text description of the image to generate (3-2000 chars) |
| `filename` | string | Yes | Desired filename for the generated image (alphanumeric, underscore, hyphen only) |
| `autoEnhance` | boolean | No | Enable automatic prompt enhancement |
| `enhancementOptions` | object | No | Enhancement configuration options (same as /api/generate) |
**Example Request:**
```bash
curl -X POST http://localhost:3000/api/text-to-image \
-H "Content-Type: application/json" \
-d '{
"prompt": "A beautiful sunset over mountains with golden clouds",
"filename": "test_sunset",
"autoEnhance": true,
"enhancementOptions": {
"imageStyle": "photorealistic",
"aspectRatio": "landscape"
}
}'
```
**Success Response (200):**
```json
{
"success": true,
"message": "Image generated successfully",
"data": {
"filename": "test_sunset.png",
"filepath": "results/test_sunset.png",
"description": "Here's a beautiful sunset over mountains with golden clouds for you!",
"model": "Nano Banana",
"generatedAt": "2025-09-26T15:04:27.705Z",
"promptEnhancement": {
"originalPrompt": "A beautiful sunset over mountains",
"enhancedPrompt": "A breathtaking photorealistic sunset over majestic mountains...",
"detectedLanguage": "English",
"appliedTemplate": "landscape",
"enhancements": ["lighting_enhancement", "composition_improvement"]
}
}
}
```
**Error Response (400/500):**
```json
{
"success": false,
"message": "Validation failed",
"error": "Prompt is required"
}
```
**Key Differences from /api/generate:**
- **JSON only**: No file upload support
- **Faster**: No multipart parsing overhead
- **Simpler testing**: Easy to use with curl or API clients
- **Same features**: Supports all enhancement options
---
### Enhance Prompt ### Enhance Prompt
#### `POST /api/enhance` #### `POST /api/enhance`

View File

@ -23,6 +23,25 @@ Content-Type: application/json
} }
} }
### Generate image from text
POST {{base}}/api/text-to-image
Content-Type: application/json
{
"prompt": "банановый стимпанк. много стимпанк машин и меаханизмов посвященных бананм и работающих на бананах. банановая феерия",
"filename": "banatie-party",
"autoEnhance": true,
"enhancementOptions": {
"imageStyle": "photorealistic",
"aspectRatio": "landscape",
"mood": "peaceful",
"lighting": "golden hour"
}
}
### Generate Image with Files ### Generate Image with Files
POST {{base}}/api/generate POST {{base}}/api/generate
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

View File

@ -4,6 +4,7 @@ import { config } from 'dotenv';
import { Config } from './types/api'; import { Config } from './types/api';
import { generateRouter } from './routes/generate'; import { generateRouter } from './routes/generate';
import { enhanceRouter } from './routes/enhance'; import { enhanceRouter } from './routes/enhance';
import { textToImageRouter } from './routes/textToImage';
import { errorHandler, notFoundHandler } from './middleware/errorHandler'; import { errorHandler, notFoundHandler } from './middleware/errorHandler';
// Load environment variables // Load environment variables
@ -63,6 +64,7 @@ export const createApp = (): Application => {
'GET /health': 'Health check', 'GET /health': 'Health check',
'GET /api/info': 'API information', 'GET /api/info': 'API information',
'POST /api/generate': 'Generate images from text prompt with optional reference images', 'POST /api/generate': 'Generate images from text prompt with optional reference images',
'POST /api/text-to-image': 'Generate images from text prompt only (JSON)',
'POST /api/enhance': 'Enhance and optimize prompts for better image generation' 'POST /api/enhance': 'Enhance and optimize prompts for better image generation'
}, },
limits: { limits: {
@ -79,6 +81,7 @@ export const createApp = (): Application => {
// Mount API routes // Mount API routes
app.use('/api', generateRouter); app.use('/api', generateRouter);
app.use('/api', enhanceRouter); app.use('/api', enhanceRouter);
app.use('/api', textToImageRouter);
// Error handling middleware (must be last) // Error handling middleware (must be last)
app.use(notFoundHandler); app.use(notFoundHandler);

View File

@ -0,0 +1,243 @@
import { Response, NextFunction } from "express";
import { sanitizeFilename } from "./validation";
// Validation rules (same as existing validation but for JSON)
const VALIDATION_RULES = {
prompt: {
minLength: 3,
maxLength: 2000,
required: true,
},
filename: {
minLength: 1,
maxLength: 100,
required: true,
pattern: /^[a-zA-Z0-9_-]+$/, // Only alphanumeric, underscore, hyphen
},
};
/**
* Validate the text-to-image JSON request
*/
export const validateTextToImageRequest = (
req: any,
res: Response,
next: NextFunction,
): void | Response => {
const timestamp = new Date().toISOString();
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
const errors: string[] = [];
console.log(
`[${timestamp}] [${req.requestId}] Validating text-to-image JSON request`,
);
// Validate that request body exists
if (!req.body || typeof req.body !== "object") {
return res.status(400).json({
success: false,
error: "Request body must be valid JSON",
message: "Invalid request format",
});
}
// Validate prompt
if (!prompt) {
errors.push("Prompt is required");
} else if (typeof prompt !== "string") {
errors.push("Prompt must be a string");
} else if (prompt.trim().length < VALIDATION_RULES.prompt.minLength) {
errors.push(
`Prompt must be at least ${VALIDATION_RULES.prompt.minLength} characters`,
);
} else if (prompt.length > VALIDATION_RULES.prompt.maxLength) {
errors.push(
`Prompt must be less than ${VALIDATION_RULES.prompt.maxLength} characters`,
);
}
// Validate filename
if (!filename) {
errors.push("Filename is required");
} else if (typeof filename !== "string") {
errors.push("Filename must be a string");
} else if (filename.trim().length < VALIDATION_RULES.filename.minLength) {
errors.push("Filename cannot be empty");
} else if (filename.length > VALIDATION_RULES.filename.maxLength) {
errors.push(
`Filename must be less than ${VALIDATION_RULES.filename.maxLength} characters`,
);
} else if (!VALIDATION_RULES.filename.pattern.test(filename)) {
errors.push(
"Filename can only contain letters, numbers, underscores, and hyphens",
);
}
// Validate autoEnhance (optional boolean)
if (autoEnhance !== undefined && typeof autoEnhance !== "boolean") {
errors.push("autoEnhance must be a boolean");
}
// Validate enhancementOptions (optional object)
if (enhancementOptions !== undefined) {
if (
typeof enhancementOptions !== "object" ||
Array.isArray(enhancementOptions)
) {
errors.push("enhancementOptions must be an object");
} else {
const {
imageStyle,
aspectRatio,
mood,
lighting,
cameraAngle,
negativePrompts,
} = enhancementOptions;
if (
imageStyle !== undefined &&
![
"photorealistic",
"illustration",
"minimalist",
"sticker",
"product",
"comic",
].includes(imageStyle)
) {
errors.push("Invalid imageStyle in enhancementOptions");
}
if (
aspectRatio !== undefined &&
!["square", "portrait", "landscape", "wide", "ultrawide"].includes(
aspectRatio,
)
) {
errors.push("Invalid aspectRatio in enhancementOptions");
}
if (
mood !== undefined &&
(typeof mood !== "string" || mood.length > 100)
) {
errors.push("mood must be a string with max 100 characters");
}
if (
lighting !== undefined &&
(typeof lighting !== "string" || lighting.length > 100)
) {
errors.push("lighting must be a string with max 100 characters");
}
if (
cameraAngle !== undefined &&
(typeof cameraAngle !== "string" || cameraAngle.length > 100)
) {
errors.push("cameraAngle must be a string with max 100 characters");
}
if (negativePrompts !== undefined) {
if (!Array.isArray(negativePrompts) || negativePrompts.length > 10) {
errors.push("negativePrompts must be an array with max 10 items");
} else {
for (const item of negativePrompts) {
if (typeof item !== "string" || item.length > 100) {
errors.push(
"Each negative prompt must be a string with max 100 characters",
);
break;
}
}
}
}
}
}
// Check for XSS attempts in prompt
const xssPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
/<object/i,
/<embed/i,
];
if (prompt && xssPatterns.some((pattern) => pattern.test(prompt))) {
errors.push("Invalid characters detected in prompt");
}
// Log validation results
if (errors.length > 0) {
console.log(
`[${timestamp}] [${req.requestId}] Validation failed: ${errors.join(", ")}`,
);
return res.status(400).json({
success: false,
error: "Validation failed",
message: errors.join(", "),
});
}
// Sanitize filename
if (filename) {
req.body.filename = sanitizeFilename(filename.trim());
if (req.body.filename !== filename.trim()) {
console.log(
`[${timestamp}] [${req.requestId}] Filename sanitized: "${filename}" -> "${req.body.filename}"`,
);
}
}
// Trim and clean prompt
if (prompt) {
req.body.prompt = prompt.trim();
}
console.log(`[${timestamp}] [${req.requestId}] JSON validation passed`);
next();
};
/**
* Log text-to-image request details for debugging
*/
export const logTextToImageRequest = (
req: any,
_res: Response,
next: NextFunction,
): void => {
const timestamp = new Date().toISOString();
const { prompt, filename, autoEnhance, enhancementOptions } = req.body;
console.log(
`[${timestamp}] [${req.requestId}] === TEXT-TO-IMAGE REQUEST ===`,
);
console.log(`[${timestamp}] [${req.requestId}] Method: ${req.method}`);
console.log(`[${timestamp}] [${req.requestId}] Path: ${req.path}`);
console.log(
`[${timestamp}] [${req.requestId}] Content-Type: ${req.get("Content-Type")}`,
);
console.log(
`[${timestamp}] [${req.requestId}] Prompt: "${prompt?.substring(0, 100)}${prompt?.length > 100 ? "..." : ""}"`,
);
console.log(`[${timestamp}] [${req.requestId}] Filename: "${filename}"`);
console.log(
`[${timestamp}] [${req.requestId}] Auto-enhance: ${autoEnhance || false}`,
);
if (enhancementOptions) {
console.log(
`[${timestamp}] [${req.requestId}] Enhancement options:`,
enhancementOptions,
);
}
console.log(
`[${timestamp}] [${req.requestId}] Reference files: 0 (text-only endpoint)`,
);
console.log(
`[${timestamp}] [${req.requestId}] =====================================`,
);
next();
};

130
src/routes/textToImage.ts Normal file
View File

@ -0,0 +1,130 @@
import { Response, Router } from "express";
import type { Router as RouterType } from "express";
import { ImageGenService } from "../services/ImageGenService";
import {
validateTextToImageRequest,
logTextToImageRequest,
} from "../middleware/jsonValidation";
import {
autoEnhancePrompt,
logEnhancementResult,
} from "../middleware/promptEnhancement";
import { asyncHandler } from "../middleware/errorHandler";
import { GenerateImageResponse } from "../types/api";
export const textToImageRouter: RouterType = Router();
let imageGenService: ImageGenService;
/**
* POST /api/text-to-image - Generate image from text prompt only (JSON)
*/
textToImageRouter.post(
"/text-to-image",
// JSON validation middleware
logTextToImageRequest,
validateTextToImageRequest,
// Auto-enhancement middleware (optional)
autoEnhancePrompt,
logEnhancementResult,
// Main handler
asyncHandler(async (req: any, res: Response) => {
// Initialize service if not already done
if (!imageGenService) {
const apiKey = process.env["GEMINI_API_KEY"];
if (!apiKey) {
return res.status(500).json({
success: false,
message: "Server configuration error",
error: "GEMINI_API_KEY not configured",
} as GenerateImageResponse);
}
imageGenService = new ImageGenService(apiKey);
}
const timestamp = new Date().toISOString();
const requestId = req.requestId;
const { prompt, filename } = req.body;
console.log(
`[${timestamp}] [${requestId}] Starting text-to-image generation process`,
);
try {
// Generate the image (no reference images for this endpoint)
console.log(
`[${timestamp}] [${requestId}] Calling ImageGenService.generateImage() (text-only)`,
);
const result = await imageGenService.generateImage({
prompt,
filename,
});
// Log the result
console.log(
`[${timestamp}] [${requestId}] Text-to-image generation completed:`,
{
success: result.success,
model: result.model,
filename: result.filename,
hasError: !!result.error,
},
);
// Send response
if (result.success) {
const successResponse: GenerateImageResponse = {
success: true,
message: "Image generated successfully",
data: {
filename: result.filename!,
filepath: result.filepath!,
...(result.description && { description: result.description }),
model: result.model,
generatedAt: timestamp,
...(req.enhancedPrompt && {
promptEnhancement: {
originalPrompt: req.originalPrompt,
enhancedPrompt: req.enhancedPrompt,
detectedLanguage: req.enhancementMetadata?.detectedLanguage,
appliedTemplate: req.enhancementMetadata?.appliedTemplate,
enhancements: req.enhancementMetadata?.enhancements || [],
},
}),
},
};
console.log(`[${timestamp}] [${requestId}] Sending success response`);
return res.status(200).json(successResponse);
} else {
const errorResponse: GenerateImageResponse = {
success: false,
message: "Image generation failed",
error: result.error || "Unknown error occurred",
};
console.log(
`[${timestamp}] [${requestId}] Sending error response: ${result.error}`,
);
return res.status(500).json(errorResponse);
}
} catch (error) {
console.error(
`[${timestamp}] [${requestId}] Unhandled error in text-to-image endpoint:`,
error,
);
const errorResponse: GenerateImageResponse = {
success: false,
message: "Image generation failed",
error:
error instanceof Error ? error.message : "Unknown error occurred",
};
return res.status(500).json(errorResponse);
}
}),
);

View File

@ -6,6 +6,26 @@ export interface GenerateImageRequest {
filename: string; filename: string;
} }
export interface TextToImageRequest {
prompt: string;
filename: string;
autoEnhance?: boolean;
enhancementOptions?: {
imageStyle?:
| "photorealistic"
| "illustration"
| "minimalist"
| "sticker"
| "product"
| "comic";
aspectRatio?: "square" | "portrait" | "landscape" | "wide" | "ultrawide";
mood?: string;
lighting?: string;
cameraAngle?: string;
negativePrompts?: string[];
};
}
export interface GenerateImageResponse { export interface GenerateImageResponse {
success: boolean; success: boolean;
message: string; message: string;