From 8623442157d25f4d9a1c10a5907c248b9070b032 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Tue, 25 Nov 2025 22:47:54 +0700 Subject: [PATCH] fix: aliases --- apps/api-service/src/routes/v1/images.ts | 237 +++++++++++++++--- .../src/services/core/AliasService.ts | 67 ++--- .../src/services/core/GenerationService.ts | 11 +- .../src/services/core/ImageService.ts | 40 +++ tests/api/02-basic.rest | 20 +- tests/api/utils.ts | 5 +- 6 files changed, 305 insertions(+), 75 deletions(-) diff --git a/apps/api-service/src/routes/v1/images.ts b/apps/api-service/src/routes/v1/images.ts index 6d2ca83..f107789 100644 --- a/apps/api-service/src/routes/v1/images.ts +++ b/apps/api-service/src/routes/v1/images.ts @@ -43,6 +43,42 @@ const getAliasService = (): AliasService => { return aliasService; }; +/** + * Resolve id_or_alias parameter to imageId + * Supports both UUID and alias (@-prefixed) identifiers + * Per Section 6.2 of api-refactoring-final.md + * + * @param identifier - UUID or alias string + * @param projectId - Project ID for alias resolution + * @param flowId - Optional flow ID for flow-scoped alias resolution + * @returns imageId (UUID) + * @throws Error if alias not found + */ +async function resolveImageIdentifier( + identifier: string, + projectId: string, + flowId?: string +): Promise { + // Check if parameter is alias (starts with @) + if (identifier.startsWith('@')) { + const aliasServiceInstance = getAliasService(); + const resolution = await aliasServiceInstance.resolve( + identifier, + projectId, + flowId + ); + + if (!resolution) { + throw new Error(`Alias '${identifier}' not found`); + } + + return resolution.imageId; + } + + // Otherwise treat as UUID + return identifier; +} + /** * Upload a single image file to project storage * @@ -206,12 +242,17 @@ imagesRouter.post( fileSize: file.size, fileHash: null, source: 'uploaded', - alias: alias || null, + alias: null, meta: meta ? JSON.parse(meta) : {}, width, height, }); + // Reassign project alias if provided (override behavior per Section 5.2) + if (alias) { + await service.reassignProjectAlias(alias, imageRecord.id, projectId); + } + // Eager flow creation if flowAlias is provided if (flowAlias) { // Use pendingFlowId if available, otherwise finalFlowId @@ -344,8 +385,21 @@ imagesRouter.get( ); /** + * @deprecated Use GET /api/v1/images/:alias directly instead (Section 6.2) + * * Resolve an alias to an image using 3-tier precedence system * + * **DEPRECATED**: This endpoint is deprecated as of Section 6.2. Use the main + * GET /api/v1/images/:id_or_alias endpoint instead, which supports both UUIDs + * and aliases (@-prefixed) directly in the path parameter. + * + * **Migration Guide**: + * - Old: GET /api/v1/images/resolve/@hero + * - New: GET /api/v1/images/@hero + * + * This endpoint remains functional for backwards compatibility but will be + * removed in a future version. + * * Resolves aliases through a priority-based lookup system: * 1. Technical aliases (@last, @first, @upload) - computed on-the-fly * 2. Flow-scoped aliases - looked up in flow's JSONB aliases field (requires flowId) @@ -359,7 +413,7 @@ imagesRouter.get( * @param {string} req.params.alias - Alias to resolve (e.g., "@last", "@hero", "@step-1") * @param {string} [req.query.flowId] - Flow context for flow-scoped resolution * - * @returns {ResolveAliasResponse} 200 - Resolved image with scope and details + * @returns {ResolveAliasResponse} 200 - Resolved image with scope and details (includes X-Deprecated header) * @returns {object} 404 - Alias not found in any scope * @returns {object} 401 - Missing or invalid API key * @@ -389,6 +443,12 @@ imagesRouter.get( const projectId = req.apiKey.projectId; + // Add deprecation header + res.setHeader( + 'X-Deprecated', + 'This endpoint is deprecated. Use GET /api/v1/images/:alias instead (Section 6.2)' + ); + try { const resolution = await aliasServiceInstance.resolve( alias, @@ -453,10 +513,11 @@ imagesRouter.get( * - File metadata (size, MIME type, hash) * - Focal point and custom metadata * - * @route GET /api/v1/images/:id + * @route GET /api/v1/images/:id_or_alias * @authentication Project Key required * - * @param {string} req.params.id - Image ID (UUID) + * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@alias) + * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution * * @returns {GetImageResponse} 200 - Complete image details * @returns {object} 404 - Image not found or access denied @@ -466,16 +527,38 @@ imagesRouter.get( * * @example * GET /api/v1/images/550e8400-e29b-41d4-a716-446655440000 + * GET /api/v1/images/@hero + * GET /api/v1/images/@hero?flowId=abc-123 */ imagesRouter.get( - '/:id', + '/:id_or_alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); - const { id } = req.params; + const { id_or_alias } = req.params; + const { flowId } = req.query; - const image = await service.getById(id); + // Resolve alias to imageId if needed (Section 6.2) + let imageId: string; + try { + imageId = await resolveImageIdentifier( + id_or_alias, + req.apiKey.projectId, + flowId as string | undefined + ); + } catch (error) { + res.status(404).json({ + success: false, + error: { + message: error instanceof Error ? error.message : 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + const image = await service.getById(imageId); if (!image) { res.status(404).json({ success: false, @@ -513,11 +596,13 @@ imagesRouter.get( * - Custom metadata (arbitrary JSON object) * * Note: Alias assignment moved to separate endpoint PUT /images/:id/alias (Section 6.1) + * Supports both UUID and alias (@-prefixed) identifiers per Section 6.2. * - * @route PUT /api/v1/images/:id + * @route PUT /api/v1/images/:id_or_alias * @authentication Project Key required * - * @param {string} req.params.id - Image ID (UUID) + * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed) + * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution * @param {UpdateImageRequest} req.body - Update parameters * @param {object} [req.body.focalPoint] - Focal point for cropping * @param {number} req.body.focalPoint.x - X coordinate (0.0-1.0) @@ -530,23 +615,55 @@ imagesRouter.get( * * @throws {Error} IMAGE_NOT_FOUND - Image does not exist * - * @example + * @example UUID identifier * PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000 * { * "focalPoint": { "x": 0.5, "y": 0.3 }, * "meta": { "category": "hero", "priority": 1 } * } + * + * @example Project-scoped alias + * PUT /api/v1/images/@hero-banner + * { + * "focalPoint": { "x": 0.5, "y": 0.3 } + * } + * + * @example Flow-scoped alias + * PUT /api/v1/images/@product-shot?flowId=123e4567-e89b-12d3-a456-426614174000 + * { + * "meta": { "category": "product" } + * } */ imagesRouter.put( - '/:id', + '/:id_or_alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); - const { id } = req.params; + const { id_or_alias } = req.params; + const { flowId } = req.query; const { focalPoint, meta } = req.body; // Removed alias (Section 6.1) - const image = await service.getById(id); + // Resolve alias to imageId if needed (Section 6.2) + let imageId: string; + try { + imageId = await resolveImageIdentifier( + id_or_alias, + req.apiKey.projectId, + flowId as string | undefined + ); + } catch (error) { + res.status(404).json({ + success: false, + error: { + message: error instanceof Error ? error.message : 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + const image = await service.getById(imageId); if (!image) { res.status(404).json({ success: false, @@ -577,7 +694,7 @@ imagesRouter.put( if (focalPoint !== undefined) updates.focalPoint = focalPoint; if (meta !== undefined) updates.meta = meta; - const updated = await service.update(id, updates); + const updated = await service.update(imageId, updates); res.json({ success: true, @@ -597,11 +714,13 @@ imagesRouter.put( * * This is a dedicated endpoint introduced in Section 6.1 to separate * alias assignment from general metadata updates. + * Supports both UUID and alias (@-prefixed) identifiers per Section 6.2. * - * @route PUT /api/v1/images/:id/alias + * @route PUT /api/v1/images/:id_or_alias/alias * @authentication Project Key required * - * @param {string} req.params.id - Image ID (UUID) + * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed) + * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution * @param {object} req.body - Request body * @param {string} req.body.alias - Project-scoped alias (e.g., "@hero-bg") * @@ -615,19 +734,32 @@ imagesRouter.put( * @throws {Error} VALIDATION_ERROR - Alias is required * @throws {Error} ALIAS_CONFLICT - Alias already assigned to another image * - * @example + * @example UUID identifier * PUT /api/v1/images/550e8400-e29b-41d4-a716-446655440000/alias * { * "alias": "@hero-background" * } + * + * @example Project-scoped alias identifier + * PUT /api/v1/images/@old-hero/alias + * { + * "alias": "@new-hero" + * } + * + * @example Flow-scoped alias identifier + * PUT /api/v1/images/@temp-product/alias?flowId=123e4567-e89b-12d3-a456-426614174000 + * { + * "alias": "@final-product" + * } */ imagesRouter.put( - '/:id/alias', + '/:id_or_alias/alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); - const { id } = req.params; + const { id_or_alias } = req.params; + const { flowId } = req.query; const { alias } = req.body; if (!alias || typeof alias !== 'string') { @@ -641,7 +773,26 @@ imagesRouter.put( return; } - const image = await service.getById(id); + // Resolve alias to imageId if needed (Section 6.2) + let imageId: string; + try { + imageId = await resolveImageIdentifier( + id_or_alias, + req.apiKey.projectId, + flowId as string | undefined + ); + } catch (error) { + res.status(404).json({ + success: false, + error: { + message: error instanceof Error ? error.message : 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + const image = await service.getById(imageId); if (!image) { res.status(404).json({ success: false, @@ -664,7 +815,7 @@ imagesRouter.put( return; } - const updated = await service.assignProjectAlias(id, alias); + const updated = await service.assignProjectAlias(imageId, alias); res.json({ success: true, @@ -685,11 +836,13 @@ imagesRouter.put( * * Use with caution: This is a destructive operation that permanently removes * the image file and all database references. + * Supports both UUID and alias (@-prefixed) identifiers per Section 6.2. * - * @route DELETE /api/v1/images/:id + * @route DELETE /api/v1/images/:id_or_alias * @authentication Project Key required * - * @param {string} req.params.id - Image ID (UUID) + * @param {string} req.params.id_or_alias - Image ID (UUID) or alias (@-prefixed) + * @param {string} [req.query.flowId] - Flow ID for flow-scoped alias resolution * * @returns {DeleteImageResponse} 200 - Deletion confirmation with image ID * @returns {object} 404 - Image not found or access denied @@ -697,7 +850,7 @@ imagesRouter.put( * * @throws {Error} IMAGE_NOT_FOUND - Image does not exist * - * @example + * @example UUID identifier * DELETE /api/v1/images/550e8400-e29b-41d4-a716-446655440000 * * Response: @@ -705,16 +858,42 @@ imagesRouter.put( * "success": true, * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" } * } + * + * @example Project-scoped alias + * DELETE /api/v1/images/@old-banner + * + * @example Flow-scoped alias + * DELETE /api/v1/images/@temp-image?flowId=123e4567-e89b-12d3-a456-426614174000 */ imagesRouter.delete( - '/:id', + '/:id_or_alias', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getImageService(); - const { id } = req.params; + const { id_or_alias } = req.params; + const { flowId } = req.query; - const image = await service.getById(id); + // Resolve alias to imageId if needed (Section 6.2) + let imageId: string; + try { + imageId = await resolveImageIdentifier( + id_or_alias, + req.apiKey.projectId, + flowId as string | undefined + ); + } catch (error) { + res.status(404).json({ + success: false, + error: { + message: error instanceof Error ? error.message : 'Image not found', + code: 'IMAGE_NOT_FOUND', + }, + }); + return; + } + + const image = await service.getById(imageId); if (!image) { res.status(404).json({ success: false, @@ -737,11 +916,11 @@ imagesRouter.delete( return; } - await service.hardDelete(id); + await service.hardDelete(imageId); res.json({ success: true, - data: { id }, + data: { id: imageId }, }); }) ); diff --git a/apps/api-service/src/services/core/AliasService.ts b/apps/api-service/src/services/core/AliasService.ts index 2aff9f1..5cd5ef2 100644 --- a/apps/api-service/src/services/core/AliasService.ts +++ b/apps/api-service/src/services/core/AliasService.ts @@ -196,42 +196,43 @@ export class AliasService { throw new Error(reservedResult.error!.message); } - if (flowId) { - await this.checkFlowAliasConflict(alias, flowId, projectId); - } else { - await this.checkProjectAliasConflict(alias, projectId); - } + // NOTE: Conflict checks removed per Section 5.2 of api-refactoring-final.md + // Aliases now use override behavior - new requests take priority over existing aliases + // Flow alias conflicts are handled by JSONB field overwrite (no check needed) } - private async checkProjectAliasConflict(alias: string, projectId: string): Promise { - const existing = await db.query.images.findFirst({ - where: and( - eq(images.projectId, projectId), - eq(images.alias, alias), - isNull(images.deletedAt), - isNull(images.flowId) - ), - }); + // DEPRECATED: Removed per Section 5.2 - aliases now use override behavior + // private async checkProjectAliasConflict(alias: string, projectId: string): Promise { + // const existing = await db.query.images.findFirst({ + // where: and( + // eq(images.projectId, projectId), + // eq(images.alias, alias), + // isNull(images.deletedAt), + // isNull(images.flowId) + // ), + // }); + // + // if (existing) { + // throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT); + // } + // } - if (existing) { - throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT); - } - } - - private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise { - const flow = await db.query.flows.findFirst({ - where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)), - }); - - if (!flow) { - throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND); - } - - const flowAliases = flow.aliases as Record; - if (flowAliases[alias]) { - throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT); - } - } + // DEPRECATED: Removed per Section 5.2 - flow aliases now use override behavior + // Flow alias conflicts are naturally handled by JSONB field overwrite in assignFlowAlias() + // private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise { + // const flow = await db.query.flows.findFirst({ + // where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)), + // }); + // + // if (!flow) { + // throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND); + // } + // + // const flowAliases = flow.aliases as Record; + // if (flowAliases[alias]) { + // throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT); + // } + // } async resolveMultiple( aliases: string[], diff --git a/apps/api-service/src/services/core/GenerationService.ts b/apps/api-service/src/services/core/GenerationService.ts index f172a48..7fcd418 100644 --- a/apps/api-service/src/services/core/GenerationService.ts +++ b/apps/api-service/src/services/core/GenerationService.ts @@ -180,12 +180,21 @@ export class GenerationService { fileSize: genResult.size || 0, fileHash, source: 'generated', - alias: params.alias || null, + alias: null, meta: params.meta || {}, width: genResult.generatedImageData?.width ?? null, height: genResult.generatedImageData?.height ?? null, }); + // Reassign project alias if provided (override behavior per Section 5.2) + if (params.alias) { + await this.imageService.reassignProjectAlias( + params.alias, + imageRecord.id, + params.projectId + ); + } + // Eager flow creation if flowAlias is provided (Section 4.2) if (params.flowAlias) { // If we have pendingFlowId, create flow and link pending generations diff --git a/apps/api-service/src/services/core/ImageService.ts b/apps/api-service/src/services/core/ImageService.ts index 5a29e94..f46ee08 100644 --- a/apps/api-service/src/services/core/ImageService.ts +++ b/apps/api-service/src/services/core/ImageService.ts @@ -257,6 +257,46 @@ export class ImageService { return updated; } + /** + * Reassign a project-scoped alias to a new image + * Clears the alias from any existing image and assigns it to the new image + * Implements override behavior per Section 5.2 of api-refactoring-final.md + * + * @param alias - The alias to reassign (e.g., "@hero") + * @param newImageId - ID of the image to receive the alias + * @param projectId - Project ID for scope validation + */ + async reassignProjectAlias( + alias: string, + newImageId: string, + projectId: string + ): Promise { + // Step 1: Clear alias from any existing image with this alias + await db + .update(images) + .set({ + alias: null, + updatedAt: new Date() + }) + .where( + and( + eq(images.projectId, projectId), + eq(images.alias, alias), + isNull(images.deletedAt), + isNull(images.flowId) + ) + ); + + // Step 2: Assign alias to new image + await db + .update(images) + .set({ + alias: alias, + updatedAt: new Date() + }) + .where(eq(images.id, newImageId)); + } + async getByStorageKey(storageKey: string): Promise { const image = await db.query.images.findFirst({ where: and( diff --git a/tests/api/02-basic.rest b/tests/api/02-basic.rest index 1e6bbce..d4f4168 100644 --- a/tests/api/02-basic.rest +++ b/tests/api/02-basic.rest @@ -97,8 +97,8 @@ X-API-Key: {{apiKey}} ### ### Test 7: Resolve project-scoped alias -# Expected: Resolves to uploadedImageId, scope=project -GET {{base}}/api/v1/images/resolve/@test-logo +# Expected: Resolves to uploadedImageId (Section 6.2: direct alias support) +GET {{base}}/api/v1/images/@test-logo X-API-Key: {{apiKey}} ### @@ -142,15 +142,15 @@ Content-Type: application/json ### ### Test 9.2: Verify new alias works -# Expected: Resolves to same uploadedImageId -GET {{base}}/api/v1/images/resolve/@new-test-logo +# Expected: Resolves to same uploadedImageId (Section 6.2: direct alias support) +GET {{base}}/api/v1/images/@new-test-logo X-API-Key: {{apiKey}} ### ### Test 10: Verify old alias doesn't work after update # Expected: 404 - Alias not found -GET {{base}}/api/v1/images/resolve/@test-logo +GET {{base}}/api/v1/images/@test-logo X-API-Key: {{apiKey}} ### @@ -176,7 +176,7 @@ X-API-Key: {{apiKey}} ### Test 11.3: Verify alias resolution fails # Expected: 404 - Alias not found -GET {{base}}/api/v1/images/resolve/@new-test-logo +GET {{base}}/api/v1/images/@new-test-logo X-API-Key: {{apiKey}} ### @@ -283,8 +283,8 @@ X-API-Key: {{apiKey}} ### ### Test 14.4: Verify alias resolution works -# Expected: Resolves to heroImageId -GET {{base}}/api/v1/images/resolve/@hero-banner +# Expected: Resolves to heroImageId (Section 6.2: direct alias support) +GET {{base}}/api/v1/images/@hero-banner X-API-Key: {{apiKey}} ### @@ -314,8 +314,8 @@ X-API-Key: {{apiKey}} @secondHeroImageId = {{genConflict.response.body.$.data.outputImageId}} ### Test 15.3: Verify second image has the alias -# Expected: Resolves to secondHeroImageId (not heroImageId) -GET {{base}}/api/v1/images/resolve/@hero-banner +# Expected: Resolves to secondHeroImageId (not heroImageId) (Section 6.2: direct alias support) +GET {{base}}/api/v1/images/@hero-banner X-API-Key: {{apiKey}} ### diff --git a/tests/api/utils.ts b/tests/api/utils.ts index e1fb6d0..4375da7 100644 --- a/tests/api/utils.ts +++ b/tests/api/utils.ts @@ -294,9 +294,10 @@ export async function resolveAlias( alias: string, flowId?: string ): Promise { + // Section 6.2: Use direct alias identifier instead of /resolve/ endpoint const endpoint = flowId - ? `${endpoints.images}/resolve/${alias}?flowId=${flowId}` - : `${endpoints.images}/resolve/${alias}`; + ? `${endpoints.images}/${alias}?flowId=${flowId}` + : `${endpoints.images}/${alias}`; const result = await api(endpoint); return result.data.data;