From 8349660eeb203dc78e8c94536d9d30d6fe4aae63 Mon Sep 17 00:00:00 2001 From: vishal Date: Fri, 7 Aug 2026 16:18:25 +0530 Subject: [PATCH 1/3] feat: accept an image URL alongside a file, and add the upload ticket client The MCP server runs in Cloud Run over HTTPS and cannot read the caller's disk, so it needs a way to pass an image that is not a local file. - add imageUrl to EditParams, AiVectorizeParams and RemoveBackgroundParams, and generationId to edit and remove-background. A refine enforces exactly one source, so existing callers passing image/file validate unchanged - skip addFileToForm when imageUrl is set, otherwise the URL string reaches fs.existsSync and throws "File not found: https://..." - add UploadClient.createTicket for the signed-upload flow. filename is required and the content type is inferred from it server-side; contentType remains an optional override - replace three hardcoded x-api-key headers with buildAuthHeaders() in the stream paths of EditClient, AIVectorizeClient and GenerateClient, which returned a guaranteed 401 for OAuth callers Scope limited to the clients the MCP uses; the other convert clients are untouched. --- src/clients/EditClient.ts | 37 +- src/clients/GenerateClient.ts | 5 +- src/clients/RemoveBackgroundClient.ts | 43 ++- src/clients/UploadClient.ts | 98 +++++ src/clients/convert/AIVectorizeClient.ts | 40 +- src/core/SVGMakerClient.ts | 7 + src/index.ts | 2 + src/types/api.ts | 52 ++- tests/clients/EditClient.test.ts | 187 ++++++++++ tests/clients/GenerateClient.test.ts | 24 ++ tests/clients/RemoveBackgroundClient.test.ts | 344 ++++++++++++++++++ tests/clients/UploadClient.test.ts | 170 +++++++++ .../clients/convert/AIVectorizeClient.test.ts | 87 +++++ tests/setup/test-setup.ts | 8 + 14 files changed, 1067 insertions(+), 37 deletions(-) create mode 100644 src/clients/UploadClient.ts create mode 100644 tests/clients/RemoveBackgroundClient.test.ts create mode 100644 tests/clients/UploadClient.test.ts diff --git a/src/clients/EditClient.ts b/src/clients/EditClient.ts index 034c92b..dbb207b 100644 --- a/src/clients/EditClient.ts +++ b/src/clients/EditClient.ts @@ -10,7 +10,9 @@ import { decodeSvgContent, decodeBase64Png } from '../utils/base64'; */ const editParamsSchema = z .object({ - image: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]), + image: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), + imageUrl: z.string().optional(), + generationId: z.string().optional(), prompt: z.string().optional(), styleParams: z .object({ @@ -44,6 +46,12 @@ const editParamsSchema = z model: z.string().optional(), raster: z.boolean().optional(), }) + .refine( + data => (data.image ? 1 : 0) + (data.imageUrl ? 1 : 0) + (data.generationId ? 1 : 0) === 1, + { + message: "Provide exactly one image source: 'image', 'imageUrl' or 'generationId'", + } + ) .refine(data => data.prompt || data.styleParams, { message: 'Either prompt or styleParams must be provided', }) @@ -89,8 +97,15 @@ export class EditClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image file - await this.addFileToForm(formData, 'image', this.params.image!); + // Add image source. addFileToForm resolves every string against the local + // filesystem, so a URL or generation id must never reach it. + if (this.params.imageUrl) { + formData.append('imageUrl', this.params.imageUrl); + } else if (this.params.generationId) { + formData.append('generationId', this.params.generationId); + } else { + await this.addFileToForm(formData, 'image', this.params.image!); + } // Add styleParams if present (requires JSON.stringify) if (this.params.styleParams) { @@ -205,8 +220,15 @@ export class EditClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image file - await this.addFileToForm(formData, 'image', client.params.image!); + // Add image source. addFileToForm resolves every string against the + // local filesystem, so a URL or generation id must never reach it. + if (client.params.imageUrl) { + formData.append('imageUrl', client.params.imageUrl); + } else if (client.params.generationId) { + formData.append('generationId', client.params.generationId); + } else { + await this.addFileToForm(formData, 'image', client.params.image!); + } // Add prompt if present if (client.params.prompt) { @@ -253,12 +275,13 @@ export class EditClient extends BaseClient { formData.append('raster', String(client.params.raster)); } - // Make request to the streaming endpoint using native fetch + // Make request to the streaming endpoint using native fetch. Auth headers + // prefer the OAuth Bearer token when present, else the x-api-key. const response = await fetch(`${this.config.baseUrl}/v1/edit`, { method: 'POST', headers: { Accept: 'text/event-stream', - 'x-api-key': this.config.apiKey, + ...this.buildAuthHeaders(), }, body: formData, }); diff --git a/src/clients/GenerateClient.ts b/src/clients/GenerateClient.ts index 95e9f5a..1acfea7 100644 --- a/src/clients/GenerateClient.ts +++ b/src/clients/GenerateClient.ts @@ -172,13 +172,14 @@ export class GenerateClient extends BaseClient { // Execute the request and handle streaming (async () => { try { - // Make request to the streaming endpoint using native fetch + // Make request to the streaming endpoint using native fetch. Auth headers + // prefer the OAuth Bearer token when present, else the x-api-key. const response = await fetch(`${this.config.baseUrl}/v1/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream', - 'x-api-key': this.config.apiKey, + ...this.buildAuthHeaders(), }, body: JSON.stringify(client.params), }); diff --git a/src/clients/RemoveBackgroundClient.ts b/src/clients/RemoveBackgroundClient.ts index e4d9b44..6802cc5 100644 --- a/src/clients/RemoveBackgroundClient.ts +++ b/src/clients/RemoveBackgroundClient.ts @@ -12,12 +12,21 @@ import { decodeSvgContent } from '../utils/base64'; /** * Schema for validating remove background parameters */ -const removeBackgroundParamsSchema = z.object({ - file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]), - stream: z.boolean().optional(), - svgText: z.boolean().optional(), - storage: z.boolean().optional(), -}); +const removeBackgroundParamsSchema = z + .object({ + file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), + imageUrl: z.string().optional(), + generationId: z.string().optional(), + stream: z.boolean().optional(), + svgText: z.boolean().optional(), + storage: z.boolean().optional(), + }) + .refine( + data => (data.file ? 1 : 0) + (data.imageUrl ? 1 : 0) + (data.generationId ? 1 : 0) === 1, + { + message: "Provide exactly one image source: 'file', 'imageUrl' or 'generationId'", + } + ); /** * Client for the Remove Background API @@ -52,8 +61,15 @@ export class RemoveBackgroundClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', this.params.file!); + // Add image source. addFileToForm resolves every string against the local + // filesystem, so a URL or generation id must never reach it. + if (this.params.imageUrl) { + formData.append('imageUrl', this.params.imageUrl); + } else if (this.params.generationId) { + formData.append('generationId', this.params.generationId); + } else { + await this.addFileToForm(formData, 'file', this.params.file!); + } // Add optional parameters this.appendOptionalParams(formData, this.params as Record, [ @@ -127,8 +143,15 @@ export class RemoveBackgroundClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', client.params.file!); + // Add image source. addFileToForm resolves every string against the + // local filesystem, so a URL or generation id must never reach it. + if (client.params.imageUrl) { + formData.append('imageUrl', client.params.imageUrl); + } else if (client.params.generationId) { + formData.append('generationId', client.params.generationId); + } else { + await this.addFileToForm(formData, 'file', client.params.file!); + } // Add storage option if present if (client.params.storage !== undefined) { diff --git a/src/clients/UploadClient.ts b/src/clients/UploadClient.ts new file mode 100644 index 0000000..8651590 --- /dev/null +++ b/src/clients/UploadClient.ts @@ -0,0 +1,98 @@ +import { BaseClient } from './BaseClient'; +import { UploadTicketParams, UploadTicketResponse } from '../types/api'; +import { SVGMakerClient } from '../core/SVGMakerClient'; +import { APIError } from '../errors/CustomErrors'; +import { z } from 'zod'; + +/** + * Schema for validating upload ticket parameters + */ +const uploadTicketParamsSchema = z.object({ + filename: z.string().min(1), + size: z.number().int().positive(), + contentType: z.string().min(1).optional(), +}); + +/** + * Client for the Upload Ticket API + * + * Mints a pair of signed URLs over a single temporary object: one to PUT the + * file bytes to, one to read it back. Bytes never pass through this SDK. + */ +export class UploadClient extends BaseClient { + /** + * Create a new Upload client + * @param client Parent SVGMaker client + */ + constructor(client: SVGMakerClient) { + super(client); + } + + /** + * Create an upload ticket + * @param params File metadata for the object to be uploaded + * @returns Signed write and read URLs + */ + public async createTicket(params: UploadTicketParams): Promise { + this.logger.debug('Creating upload ticket', { + filename: params.filename, + size: params.size, + }); + + this.validateRequest(params, uploadTicketParamsSchema); + + // Raw fetch bypasses the retry/timeout wrapper applied to httpClient.request. + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.config.timeout); + + let response: globalThis.Response; + try { + response = await fetch(`${this.config.baseUrl}/v1/upload/ticket`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.buildAuthHeaders(), + }, + body: JSON.stringify({ + filename: params.filename, + content_type: params.contentType, + size: params.size, + }), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + throw new APIError(`Upload ticket request timed out after ${this.config.timeout}ms`, 408); + } + throw error; + } finally { + clearTimeout(timer); + } + + if (!response.ok) { + await this.handleFetchErrorResponse(response); + } + + const rawResult = await response.json(); + const { data, metadata: responseMetadata } = this.unwrapEnvelope(rawResult); + + this.logger.debug('Upload ticket created'); + + return { + putUrl: data.put_url, + fileUrl: data.file_url, + contentType: data.content_type, + metadata: responseMetadata, + }; + } + + /** + * Create a clone of this client + * @returns New client instance + */ + protected clone(): UploadClient { + const client = new UploadClient(this.client); + this.copyTo(client); + return client; + } +} diff --git a/src/clients/convert/AIVectorizeClient.ts b/src/clients/convert/AIVectorizeClient.ts index c2beca6..212b95d 100644 --- a/src/clients/convert/AIVectorizeClient.ts +++ b/src/clients/convert/AIVectorizeClient.ts @@ -8,12 +8,17 @@ import { decodeSvgContent } from '../../utils/base64'; /** * Schema for validating AI vectorize parameters */ -const aiVectorizeParamsSchema = z.object({ - file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]), - storage: z.boolean().optional(), - stream: z.boolean().optional(), - svgText: z.boolean().optional(), -}); +const aiVectorizeParamsSchema = z + .object({ + file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), + imageUrl: z.string().optional(), + storage: z.boolean().optional(), + stream: z.boolean().optional(), + svgText: z.boolean().optional(), + }) + .refine(data => (data.file ? 1 : 0) + (data.imageUrl ? 1 : 0) === 1, { + message: "Provide exactly one image source: either 'file' or 'imageUrl'", + }); /** * Client for the AI Vectorize (Convert Image to SVG) API @@ -45,8 +50,13 @@ export class AIVectorizeClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', this.params.file!); + // Add image source. addFileToForm resolves every string against the local + // filesystem, so a URL must never reach it. + if (this.params.imageUrl) { + formData.append('imageUrl', this.params.imageUrl); + } else { + await this.addFileToForm(formData, 'file', this.params.file!); + } // Add optional parameters this.appendOptionalParams(formData, this.params as Record, [ @@ -120,8 +130,13 @@ export class AIVectorizeClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add file - await this.addFileToForm(formData, 'file', client.params.file!); + // Add image source. addFileToForm resolves every string against the + // local filesystem, so a URL must never reach it. + if (client.params.imageUrl) { + formData.append('imageUrl', client.params.imageUrl); + } else { + await this.addFileToForm(formData, 'file', client.params.file!); + } // Add storage option if present if (client.params.storage !== undefined) { @@ -135,12 +150,13 @@ export class AIVectorizeClient extends BaseClient { formData.append('svgText', String(client.params.svgText)); } - // Make request to the streaming endpoint using native fetch + // Make request to the streaming endpoint using native fetch. Auth headers + // prefer the OAuth Bearer token when present, else the x-api-key. const response = await fetch(`${this.config.baseUrl}/v1/convert/ai-vectorize`, { method: 'POST', headers: { Accept: 'text/event-stream', - 'x-api-key': this.config.apiKey, + ...this.buildAuthHeaders(), }, body: formData, }); diff --git a/src/core/SVGMakerClient.ts b/src/core/SVGMakerClient.ts index b294f81..288edf8 100644 --- a/src/core/SVGMakerClient.ts +++ b/src/core/SVGMakerClient.ts @@ -14,6 +14,7 @@ import { GalleryClient } from '../clients/GalleryClient'; import { AccountClient } from '../clients/AccountClient'; import { OptimizeSvgClient } from '../clients/OptimizeSvgClient'; import { RemoveBackgroundClient } from '../clients/RemoveBackgroundClient'; +import { UploadClient } from '../clients/UploadClient'; import { createRetryWrapper } from '../utils/retry'; import { createRateLimiter } from '../utils/rateLimit'; import { Logger, createLogger } from '../utils/logger'; @@ -113,6 +114,11 @@ export class SVGMakerClient { */ public readonly enhancePrompt: EnhancePromptClient; + /** + * Upload client for minting signed upload tickets + */ + public readonly upload: UploadClient; + /** * Create a new SVGMaker client * @param apiKey API key for authentication @@ -163,6 +169,7 @@ export class SVGMakerClient { this.gallery = new GalleryClient(this); this.account = new AccountClient(this); this.optimizeSvg = new OptimizeSvgClient(this); + this.upload = new UploadClient(this); this.logger.info('SVGMaker SDK initialized'); } diff --git a/src/index.ts b/src/index.ts index f995a0e..d5dd25e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { RasterToRasterClient } from './clients/convert/RasterToRasterClient'; import { BatchConvertClient } from './clients/convert/BatchConvertClient'; import { EnhancePromptClient } from './clients/EnhancePromptClient'; import { RemoveBackgroundClient } from './clients/RemoveBackgroundClient'; +import { UploadClient } from './clients/UploadClient'; // Export error classes import * as Errors from './errors/CustomErrors'; @@ -46,6 +47,7 @@ export { BatchConvertClient, EnhancePromptClient, RemoveBackgroundClient, + UploadClient, // Utils HttpClient, diff --git a/src/types/api.ts b/src/types/api.ts index 2c5da0e..d5f04e6 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -101,8 +101,14 @@ export interface GenerateParams { * Edit SVG/Image request parameters */ export interface EditParams { - /** Required: Image file to edit */ - image: string | Buffer | Readable; + /** Image file to edit. Provide exactly one of `image`, `imageUrl` or `generationId`. */ + image?: string | Buffer | Readable; + + /** Publicly reachable https URL the API fetches itself. Provide exactly one of `image`, `imageUrl` or `generationId`. */ + imageUrl?: string; + + /** Id of a previous generation the API resolves internally. Provide exactly one of `image`, `imageUrl` or `generationId`. */ + generationId?: string; /** Optional: Edit instructions as a simple text string */ prompt?: string; @@ -142,8 +148,11 @@ export interface EditParams { * AI Vectorize (Convert Image to SVG) request parameters */ export interface AiVectorizeParams { - /** Required: File to convert to SVG */ - file: string | Buffer | Readable; + /** File to convert to SVG. Provide exactly one of `file` or `imageUrl`. */ + file?: string | Buffer | Readable; + + /** Publicly reachable https URL the API fetches itself. Provide exactly one of `file` or `imageUrl`. */ + imageUrl?: string; /** Optional: Enable streaming response (default: false) */ stream?: boolean; @@ -168,8 +177,14 @@ export type ConvertParams = AiVectorizeParams; * transparency. Accepts any raster image (PNG, JPEG, WebP, etc.) or SVG. */ export interface RemoveBackgroundParams { - /** Required: Image file to remove the background from */ - file: string | Buffer | Readable; + /** Image file to remove the background from. Provide exactly one of `file`, `imageUrl` or `generationId`. */ + file?: string | Buffer | Readable; + + /** Publicly reachable https URL the API fetches itself. Provide exactly one of `file`, `imageUrl` or `generationId`. */ + imageUrl?: string; + + /** Id of a previous generation the API resolves internally. Provide exactly one of `file`, `imageUrl` or `generationId`. */ + generationId?: string; /** Optional: Enable streaming response (default: false) */ stream?: boolean; @@ -286,6 +301,31 @@ export interface OptimizeSvgResponse { metadata: ResponseMetadata; } +// --- Upload Ticket Types --- + +export interface UploadTicketParams { + /** Required: File name. Its extension determines the content type; the stored object name is generated server-side */ + filename: string; + /** Required: Size of the file in bytes */ + size: number; + /** Optional: MIME type override, for when the extension is missing or misleading */ + contentType?: string; +} + +export interface UploadTicketResponse { + /** + * Signed URL to PUT the file bytes to. The request must send + * `Content-Type: ` — that value is bound into the signature. + */ + putUrl: string; + /** Signed read URL to pass back as `imageUrl` on a later edit/convert/remove-background call */ + fileUrl: string; + /** The resolved MIME type — send this as the Content-Type header of the PUT */ + contentType: string; + /** Response metadata */ + metadata: ResponseMetadata; +} + // --- Convert Results Types (shared by trace, svg-to-vector, raster-to-raster, batch) --- export interface ConvertResultItem { diff --git a/tests/clients/EditClient.test.ts b/tests/clients/EditClient.test.ts index 5add784..069f8d8 100644 --- a/tests/clients/EditClient.test.ts +++ b/tests/clients/EditClient.test.ts @@ -9,6 +9,8 @@ import { mockFetchStreamResponse, createMockEditResponse, createMockStreamEvents, + createOAuthTestClient, + TEST_ACCESS_TOKEN, } from '../setup'; import { ValidationError, @@ -588,4 +590,189 @@ describe('EditClient', () => { expect(generatedEvent.pngImageData).toBeInstanceOf(Buffer); }); }); + + // ========================================================================== + // imageUrl source + // ========================================================================== + + describe('imageUrl', () => { + const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends imageUrl as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit.configure({ imageUrl: IMAGE_URL, prompt: 'Make it blue' }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(formData.get('image')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends imageUrl on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ imageUrl: IMAGE_URL, prompt: 'Make it blue' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when neither image nor imageUrl is provided', async () => { + const client = createTestClient(); + + await expect(client.edit.configure({ prompt: 'Make it blue' }).execute()).rejects.toThrow( + ValidationError + ); + }); + + it('throws ValidationError when both image and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ image: testImageBuffer, imageUrl: IMAGE_URL, prompt: 'Make it blue' }) + .execute() + ).rejects.toThrow(ValidationError); + }); + + it('accepts an image-only call', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit.configure({ image: testImageBuffer, prompt: 'Make it blue' }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('image')).toBeTruthy(); + expect(formData.get('imageUrl')).toBeNull(); + }); + }); + + // ========================================================================== + // generationId source + // ========================================================================== + + describe('generationId', () => { + const GENERATION_ID = 'gen-abc-123'; + const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends generationId as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit + .configure({ generationId: GENERATION_ID, prompt: 'Make it blue' }) + .execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('image')).toBeNull(); + expect(formData.get('imageUrl')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends generationId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ generationId: GENERATION_ID, prompt: 'Make it blue' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('image')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when both generationId and image are provided', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ + image: testImageBuffer, + generationId: GENERATION_ID, + prompt: 'Make it blue', + }) + .execute() + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both generationId and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ + imageUrl: IMAGE_URL, + generationId: GENERATION_ID, + prompt: 'Make it blue', + }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + // ========================================================================== + // Authentication + // ========================================================================== + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ image: testImageBuffer, prompt: 'Edit this' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); }); diff --git a/tests/clients/GenerateClient.test.ts b/tests/clients/GenerateClient.test.ts index f3388b4..5d494dd 100644 --- a/tests/clients/GenerateClient.test.ts +++ b/tests/clients/GenerateClient.test.ts @@ -8,6 +8,8 @@ import { mockFetchStreamResponse, createMockGenerateResponse, createMockStreamEvents, + createOAuthTestClient, + TEST_ACCESS_TOKEN, } from '../setup'; import { ValidationError, RateLimitError, APIError } from '../../src/errors/CustomErrors'; @@ -551,4 +553,26 @@ describe('GenerateClient', () => { expect(completeEvent.generationId).toBe('gen-stream-001'); }); }); + // ========================================================================== + // Authentication + // ========================================================================== + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.generate.configure({ prompt: 'A cat' }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); }); diff --git a/tests/clients/RemoveBackgroundClient.test.ts b/tests/clients/RemoveBackgroundClient.test.ts new file mode 100644 index 0000000..04d46d4 --- /dev/null +++ b/tests/clients/RemoveBackgroundClient.test.ts @@ -0,0 +1,344 @@ +import fs from 'fs'; +import { Readable } from 'stream'; +import { + createTestClient, + setupFetchMock, + cleanupMocks, + mockFetchJsonResponse, + mockFetchErrorResponse, + mockFetchStreamResponse, + createOAuthTestClient, + TEST_ACCESS_TOKEN, +} from '../setup'; +import { + ValidationError, + AuthError, + InsufficientCreditsError, + RateLimitError, + APIError, +} from '../../src/errors/CustomErrors'; + +const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + +function mockRemoveBackgroundData(overrides?: Record): Record { + return { + svgUrl: 'https://storage.example.com/no-background.svg', + creditCost: 1, + message: 'Background removed successfully', + svgUrlExpiresIn: '24h', + generationId: 'rmbg-test-123', + ...overrides, + }; +} + +describe('RemoveBackgroundClient', () => { + let fetchMock: jest.Mock; + const testFileBuffer = Buffer.from('fake-png-image-data'); + + beforeEach(() => { + fetchMock = setupFetchMock(); + }); + + afterEach(() => { + cleanupMocks(); + }); + + describe('.configure()', () => { + it('returns a new instance (immutability)', () => { + const client = createTestClient(); + const original = client.removeBackground; + + expect(original.configure({ file: testFileBuffer })).not.toBe(original); + }); + }); + + describe('.execute()', () => { + it('sends POST to /v1/remove-background with FormData', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ file: testFileBuffer }).execute(); + + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toContain('/v1/remove-background'); + expect(options.method).toBe('POST'); + expect(options.body).toBeInstanceOf(FormData); + }); + + it('appends optional params to FormData', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground + .configure({ file: testFileBuffer, storage: true, svgText: true }) + .execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('file')).toBeTruthy(); + expect(formData.get('storage')).toBe('true'); + expect(formData.get('svgText')).toBe('true'); + }); + + it('returns a properly shaped RemoveBackgroundResponse', async () => { + const client = createTestClient(); + const rawSvg = ''; + mockFetchJsonResponse(mockRemoveBackgroundData({ svgText: rawSvg })); + + const result = await client.removeBackground.configure({ file: testFileBuffer }).execute(); + + expect(result.svgUrl).toBe('https://storage.example.com/no-background.svg'); + expect(result.creditCost).toBe(1); + expect(result.generationId).toBe('rmbg-test-123'); + expect(result.svgText).toBe(rawSvg); + expect(result.metadata).toBeDefined(); + }); + + it('accepts file as a Readable stream', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + const readable = new Readable(); + readable.push(Buffer.from('fake-stream-data')); + readable.push(null); + + await client.removeBackground.configure({ file: readable }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('file')).toBeTruthy(); + }); + }); + + describe('imageUrl', () => { + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends imageUrl as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ imageUrl: IMAGE_URL }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends imageUrl on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ imageUrl: IMAGE_URL }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when neither file nor imageUrl is provided', async () => { + const client = createTestClient(); + + await expect(client.removeBackground.execute()).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both file and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground.configure({ file: testFileBuffer, imageUrl: IMAGE_URL }).execute() + ).rejects.toThrow(ValidationError); + }); + + it('accepts a file-only call', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ file: testFileBuffer }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('file')).toBeTruthy(); + expect(formData.get('imageUrl')).toBeNull(); + }); + }); + + describe('generationId', () => { + const GENERATION_ID = 'gen-abc-123'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends generationId as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ generationId: GENERATION_ID }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('file')).toBeNull(); + expect(formData.get('imageUrl')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends generationId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ generationId: GENERATION_ID }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('generationId')).toBe(GENERATION_ID); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when both generationId and file are provided', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground + .configure({ file: testFileBuffer, generationId: GENERATION_ID }) + .execute() + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both generationId and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground + .configure({ imageUrl: IMAGE_URL, generationId: GENERATION_ID }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + describe('.stream()', () => { + it('emits accumulated fields on the complete event', async () => { + const client = createTestClient(); + const rawSvg = ''; + mockFetchStreamResponse([ + { status: 'processing', message: 'Removing background...' }, + { status: 'generated', message: 'SVG generated', svgText: rawSvg, creditCost: 1 }, + { status: 'complete', message: 'Done', generationId: 'rmbg-stream-001' }, + ]); + + const stream = client.removeBackground + .configure({ file: testFileBuffer, svgText: true, storage: true }) + .stream(); + + const events: any[] = []; + await new Promise((resolve, reject) => { + stream.on('data', (event: any) => events.push(event)); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const completeEvent = events.find(e => e.status === 'complete'); + expect(completeEvent.svgText).toBe(rawSvg); + expect(completeEvent.creditCost).toBe(1); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('stream')).toBe('true'); + }); + + it('emits an error on a non-ok response', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INTERNAL_ERROR', 500); + + const stream = client.removeBackground.configure({ file: testFileBuffer }).stream(); + + await expect( + new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }) + ).rejects.toBeDefined(); + }); + }); + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ file: testFileBuffer }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); + + describe('error handling', () => { + it('throws AuthError on INVALID_API_KEY', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INVALID_API_KEY', 401); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(AuthError); + }); + + it('throws InsufficientCreditsError on INSUFFICIENT_CREDITS', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INSUFFICIENT_CREDITS', 402); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(InsufficientCreditsError); + }); + + it('throws RateLimitError on RATE_LIMIT_EXCEEDED', async () => { + const client = createTestClient(); + mockFetchErrorResponse('RATE_LIMIT_EXCEEDED', 429); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(RateLimitError); + }); + + it('throws APIError on a generic server error', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INTERNAL_ERROR', 500); + + await expect( + client.removeBackground.configure({ file: testFileBuffer }).execute() + ).rejects.toThrow(APIError); + }); + }); +}); diff --git a/tests/clients/UploadClient.test.ts b/tests/clients/UploadClient.test.ts new file mode 100644 index 0000000..de5dc1c --- /dev/null +++ b/tests/clients/UploadClient.test.ts @@ -0,0 +1,170 @@ +import { + createTestClient, + setupFetchMock, + cleanupMocks, + mockFetchJsonResponse, + mockFetchErrorResponse, + createOAuthTestClient, + TEST_ACCESS_TOKEN, +} from '../setup'; +import { + ValidationError, + AuthError, + RateLimitError, + APIError, +} from '../../src/errors/CustomErrors'; + +const TICKET = { + put_url: 'https://storage.example.com/temp-uploads/user/abc.png?X-Goog-Signature=write', + file_url: 'https://storage.example.com/temp-uploads/user/abc.png?X-Goog-Signature=read', + content_type: 'image/png', +}; + +const VALID_PARAMS = { filename: 'panda.png', size: 482113 }; + +describe('UploadClient', () => { + let fetchMock: jest.Mock; + + beforeEach(() => { + fetchMock = setupFetchMock(); + }); + + afterEach(() => { + cleanupMocks(); + }); + + describe('.createTicket()', () => { + it('sends a JSON POST to /v1/upload/ticket with snake_case fields', async () => { + const client = createTestClient(); + mockFetchJsonResponse(TICKET); + + await client.upload.createTicket(VALID_PARAMS); + + const [url, options] = fetchMock.mock.calls[0]; + expect(url).toContain('/v1/upload/ticket'); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('application/json'); + expect(options.headers['x-api-key']).toBe('test-api-key-123'); + expect(JSON.parse(options.body)).toEqual({ + filename: 'panda.png', + content_type: undefined, + size: 482113, + }); + }); + + it('passes an abort signal so a hung request cannot wedge the caller', async () => { + const client = createTestClient(); + mockFetchJsonResponse(TICKET); + + await client.upload.createTicket(VALID_PARAMS); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.signal).toBeInstanceOf(AbortSignal); + }); + + it('maps the snake_case envelope to camelCase', async () => { + const client = createTestClient(); + mockFetchJsonResponse(TICKET); + + const result = await client.upload.createTicket(VALID_PARAMS); + + expect(result.putUrl).toBe(TICKET.put_url); + expect(result.fileUrl).toBe(TICKET.file_url); + expect(result.contentType).toBe(TICKET.content_type); + expect(result.metadata.requestId).toBe('test-req-id'); + }); + + it('sends a Bearer token when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchJsonResponse(TICKET); + + await client.upload.createTicket(VALID_PARAMS); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + + it('forwards an explicit contentType override', async () => { + const client = createTestClient(); + mockFetchJsonResponse(TICKET); + + await client.upload.createTicket({ filename: 'blob', size: 10, contentType: 'image/png' }); + + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.filename).toBe('blob'); + expect(body.content_type).toBe('image/png'); + }); + }); + + describe('validation', () => { + it('throws ValidationError when filename is missing or empty', async () => { + const client = createTestClient(); + + await expect(client.upload.createTicket({ filename: '', size: 10 })).rejects.toThrow( + ValidationError + ); + await expect( + client.upload.createTicket({ size: 10 } as unknown as { filename: string; size: number }) + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when an explicit contentType is empty', async () => { + const client = createTestClient(); + + await expect( + client.upload.createTicket({ filename: 'panda.png', size: 10, contentType: '' }) + ).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when size is not a positive integer', async () => { + const client = createTestClient(); + + await expect(client.upload.createTicket({ filename: 'panda.png', size: 0 })).rejects.toThrow( + ValidationError + ); + await expect( + client.upload.createTicket({ filename: 'panda.png', size: 1.5 }) + ).rejects.toThrow(ValidationError); + }); + }); + + describe('error handling', () => { + it('throws AuthError on INVALID_API_KEY', async () => { + const client = createTestClient(); + mockFetchErrorResponse('INVALID_API_KEY', 401); + + await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(AuthError); + }); + + it('throws RateLimitError on RATE_LIMIT_EXCEEDED', async () => { + const client = createTestClient(); + mockFetchErrorResponse('RATE_LIMIT_EXCEEDED', 429); + + await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(RateLimitError); + }); + + it('throws APIError on VALIDATION_ERROR from the API', async () => { + const client = createTestClient(); + mockFetchErrorResponse('VALIDATION_ERROR', 400, 'size exceeds the 25MB limit.'); + + await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(APIError); + }); + + it('throws a 408 APIError when the request is aborted', async () => { + const client = createTestClient(); + const abortError = new Error('aborted'); + abortError.name = 'AbortError'; + fetchMock.mockRejectedValue(abortError); + + await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(/timed out/); + }); + + it('rethrows non-abort network errors unchanged', async () => { + const client = createTestClient(); + fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow('ECONNREFUSED'); + }); + }); +}); diff --git a/tests/clients/convert/AIVectorizeClient.test.ts b/tests/clients/convert/AIVectorizeClient.test.ts index 351ceaf..63181f6 100644 --- a/tests/clients/convert/AIVectorizeClient.test.ts +++ b/tests/clients/convert/AIVectorizeClient.test.ts @@ -9,6 +9,8 @@ import { mockFetchStreamResponse, createMockAiVectorizeResponse, createMockAiVectorizeStreamEvents, + createOAuthTestClient, + TEST_ACCESS_TOKEN, } from '../../setup'; import { ValidationError, @@ -398,4 +400,89 @@ describe('AIVectorizeClient', () => { ).rejects.toBeDefined(); }); }); + // ========================================================================== + // imageUrl source + // ========================================================================== + + describe('imageUrl', () => { + const IMAGE_URL = 'https://storage.example.com/temp-uploads/user/abc.png'; + let originalExistsSync: any; + + beforeEach(() => { + originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + fs.existsSync = originalExistsSync; + }); + + it('sends imageUrl as a form field without touching the filesystem', async () => { + const client = createTestClient(); + mockFetchJsonResponse(createMockAiVectorizeResponse()); + + await client.convert.aiVectorize.configure({ imageUrl: IMAGE_URL }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('sends imageUrl on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.convert.aiVectorize.configure({ imageUrl: IMAGE_URL }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('imageUrl')).toBe(IMAGE_URL); + expect(fs.existsSync).not.toHaveBeenCalled(); + }); + + it('throws ValidationError when neither file nor imageUrl is provided', async () => { + const client = createTestClient(); + + await expect(client.convert.aiVectorize.execute()).rejects.toThrow(ValidationError); + }); + + it('throws ValidationError when both file and imageUrl are provided', async () => { + const client = createTestClient(); + + await expect( + client.convert.aiVectorize + .configure({ file: testFileBuffer, imageUrl: IMAGE_URL }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + + // ========================================================================== + // Authentication + // ========================================================================== + + describe('authentication', () => { + it('sends a Bearer token on the streaming path when an access token is configured', async () => { + const client = createOAuthTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.convert.aiVectorize.configure({ file: testFileBuffer }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const [, options] = fetchMock.mock.calls[0]; + expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); + expect(options.headers['x-api-key']).toBeUndefined(); + }); + }); }); diff --git a/tests/setup/test-setup.ts b/tests/setup/test-setup.ts index 78b9bd4..90e636a 100644 --- a/tests/setup/test-setup.ts +++ b/tests/setup/test-setup.ts @@ -7,6 +7,7 @@ import { SVGMakerClient } from '../../src/core/SVGMakerClient'; export const TEST_API_KEY = 'test-api-key-123'; export const TEST_BASE_URL = 'https://api.svgmaker.io'; +export const TEST_ACCESS_TOKEN = 'test-access-token-abc'; /** * Default test configuration with retries disabled @@ -35,6 +36,13 @@ export function createTestClient(): SVGMakerClient { return new SVGMakerClient(TEST_API_KEY, DEFAULT_TEST_CONFIG); } +/** + * Create a client authenticated with an OAuth access token and no API key + */ +export function createOAuthTestClient(): SVGMakerClient { + return new SVGMakerClient('', { ...DEFAULT_TEST_CONFIG, accessToken: TEST_ACCESS_TOKEN }); +} + // ============================================================================ // Fetch Mock Helpers // ============================================================================ From abfdae5d04224c1c3af431551fe4832dd30e819f Mon Sep 17 00:00:00 2001 From: vishal Date: Mon, 10 Aug 2026 13:12:29 +0530 Subject: [PATCH 2/3] feat: accept an uploadId image source and prepare temporary uploads Follows the API move from a signed GCS upload ticket to a tokenized upload endpoint that returns an id. - add uploadId to EditParams, AiVectorizeParams and RemoveBackgroundParams. The refine now counts four sources and still requires exactly one, so existing callers passing image/file validate unchanged - replace UploadClient.createTicket with prepare({ filename }), returning the short-lived multipart upload URL and its expiry - add BaseClient.appendImageSource, which replaces six copies of the same image-source branch across the edit, vectorize and remove-background clients (each had one on the normal path and one on the stream path) --- CHANGELOG.md | 8 ++ src/clients/BaseClient.ts | 33 +++++++ src/clients/EditClient.ts | 31 +++---- src/clients/RemoveBackgroundClient.ts | 30 +++---- src/clients/UploadClient.ts | 46 ++++------ src/clients/convert/AIVectorizeClient.ts | 21 ++--- src/types/api.ts | 54 +++++------ tests/clients/EditClient.test.ts | 51 +++++++++++ tests/clients/RemoveBackgroundClient.test.ts | 47 ++++++++++ tests/clients/UploadClient.test.ts | 90 ++++++------------- .../clients/convert/AIVectorizeClient.test.ts | 49 ++++++++++ 11 files changed, 286 insertions(+), 174 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 693081f..0b9428a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `imageUrl` on `EditParams`, `AiVectorizeParams` and `RemoveBackgroundParams`, and `generationId` on edit and remove-background, so an image can be supplied without reading a local file +- `uploadId` on the same three, accepting an id from the temporary upload endpoint +- `client.upload.prepare({ filename })`, which returns a short-lived multipart upload URL and its expiry +- Exactly one image source must be set. Existing callers passing `image`/`file` validate unchanged + ### Fixed +- `EditClient`, `AIVectorizeClient` and `GenerateClient` sent a hardcoded `x-api-key` header on their stream paths, which returned a guaranteed 401 for OAuth callers. They now use `buildAuthHeaders()` +- A URL no longer reaches `addFileToForm`, which resolves every string against the local filesystem and threw `File not found: https://...` - Fix AI-enhanced release notes failing silently due to missing `models: read` permission in release job - Add test workflow for verifying GitHub Models API access diff --git a/src/clients/BaseClient.ts b/src/clients/BaseClient.ts index 7003df7..05efdab 100644 --- a/src/clients/BaseClient.ts +++ b/src/clients/BaseClient.ts @@ -151,6 +151,39 @@ export abstract class BaseClient { } } + /** + * Append the single image source the params carry. addFileToForm resolves + * every string against the local filesystem, so a URL or an id must never + * reach it — the schemas guarantee exactly one source is set. + * @param formData Form data + * @param params Validated request params + * @param fileField Field name the endpoint expects the raw file under + */ + protected async appendImageSource( + formData: FormData, + params: { + imageUrl?: string; + generationId?: string; + uploadId?: string; + [key: string]: unknown; + }, + fileField: 'image' | 'file' + ): Promise { + if (params.imageUrl) { + formData.append('imageUrl', params.imageUrl); + } else if (params.generationId) { + formData.append('generationId', params.generationId); + } else if (params.uploadId) { + formData.append('uploadId', params.uploadId); + } else { + await this.addFileToForm( + formData, + fileField, + params[fileField] as string | Buffer | Readable + ); + } + } + /** * Get MIME type from filename * @param filename File name diff --git a/src/clients/EditClient.ts b/src/clients/EditClient.ts index dbb207b..b065026 100644 --- a/src/clients/EditClient.ts +++ b/src/clients/EditClient.ts @@ -13,6 +13,7 @@ const editParamsSchema = z image: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), imageUrl: z.string().optional(), generationId: z.string().optional(), + uploadId: z.string().optional(), prompt: z.string().optional(), styleParams: z .object({ @@ -47,9 +48,15 @@ const editParamsSchema = z raster: z.boolean().optional(), }) .refine( - data => (data.image ? 1 : 0) + (data.imageUrl ? 1 : 0) + (data.generationId ? 1 : 0) === 1, + data => + (data.image ? 1 : 0) + + (data.imageUrl ? 1 : 0) + + (data.generationId ? 1 : 0) + + (data.uploadId ? 1 : 0) === + 1, { - message: "Provide exactly one image source: 'image', 'imageUrl' or 'generationId'", + message: + "Provide exactly one image source: 'image', 'imageUrl', 'generationId' or 'uploadId'", } ) .refine(data => data.prompt || data.styleParams, { @@ -97,15 +104,7 @@ export class EditClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image source. addFileToForm resolves every string against the local - // filesystem, so a URL or generation id must never reach it. - if (this.params.imageUrl) { - formData.append('imageUrl', this.params.imageUrl); - } else if (this.params.generationId) { - formData.append('generationId', this.params.generationId); - } else { - await this.addFileToForm(formData, 'image', this.params.image!); - } + await this.appendImageSource(formData, this.params, 'image'); // Add styleParams if present (requires JSON.stringify) if (this.params.styleParams) { @@ -220,15 +219,7 @@ export class EditClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image source. addFileToForm resolves every string against the - // local filesystem, so a URL or generation id must never reach it. - if (client.params.imageUrl) { - formData.append('imageUrl', client.params.imageUrl); - } else if (client.params.generationId) { - formData.append('generationId', client.params.generationId); - } else { - await this.addFileToForm(formData, 'image', client.params.image!); - } + await this.appendImageSource(formData, client.params, 'image'); // Add prompt if present if (client.params.prompt) { diff --git a/src/clients/RemoveBackgroundClient.ts b/src/clients/RemoveBackgroundClient.ts index 6802cc5..13f02cd 100644 --- a/src/clients/RemoveBackgroundClient.ts +++ b/src/clients/RemoveBackgroundClient.ts @@ -17,14 +17,20 @@ const removeBackgroundParamsSchema = z file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), imageUrl: z.string().optional(), generationId: z.string().optional(), + uploadId: z.string().optional(), stream: z.boolean().optional(), svgText: z.boolean().optional(), storage: z.boolean().optional(), }) .refine( - data => (data.file ? 1 : 0) + (data.imageUrl ? 1 : 0) + (data.generationId ? 1 : 0) === 1, + data => + (data.file ? 1 : 0) + + (data.imageUrl ? 1 : 0) + + (data.generationId ? 1 : 0) + + (data.uploadId ? 1 : 0) === + 1, { - message: "Provide exactly one image source: 'file', 'imageUrl' or 'generationId'", + message: "Provide exactly one image source: 'file', 'imageUrl', 'generationId' or 'uploadId'", } ); @@ -61,15 +67,7 @@ export class RemoveBackgroundClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image source. addFileToForm resolves every string against the local - // filesystem, so a URL or generation id must never reach it. - if (this.params.imageUrl) { - formData.append('imageUrl', this.params.imageUrl); - } else if (this.params.generationId) { - formData.append('generationId', this.params.generationId); - } else { - await this.addFileToForm(formData, 'file', this.params.file!); - } + await this.appendImageSource(formData, this.params, 'file'); // Add optional parameters this.appendOptionalParams(formData, this.params as Record, [ @@ -143,15 +141,7 @@ export class RemoveBackgroundClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image source. addFileToForm resolves every string against the - // local filesystem, so a URL or generation id must never reach it. - if (client.params.imageUrl) { - formData.append('imageUrl', client.params.imageUrl); - } else if (client.params.generationId) { - formData.append('generationId', client.params.generationId); - } else { - await this.addFileToForm(formData, 'file', client.params.file!); - } + await this.appendImageSource(formData, client.params, 'file'); // Add storage option if present if (client.params.storage !== undefined) { diff --git a/src/clients/UploadClient.ts b/src/clients/UploadClient.ts index 8651590..9828e8f 100644 --- a/src/clients/UploadClient.ts +++ b/src/clients/UploadClient.ts @@ -1,23 +1,18 @@ import { BaseClient } from './BaseClient'; -import { UploadTicketParams, UploadTicketResponse } from '../types/api'; +import { UploadPrepareParams, UploadPrepareResponse } from '../types/api'; import { SVGMakerClient } from '../core/SVGMakerClient'; import { APIError } from '../errors/CustomErrors'; import { z } from 'zod'; /** - * Schema for validating upload ticket parameters + * Schema for validating upload preparation parameters */ -const uploadTicketParamsSchema = z.object({ +const uploadPrepareParamsSchema = z.object({ filename: z.string().min(1), - size: z.number().int().positive(), - contentType: z.string().min(1).optional(), }); /** - * Client for the Upload Ticket API - * - * Mints a pair of signed URLs over a single temporary object: one to PUT the - * file bytes to, one to read it back. Bytes never pass through this SDK. + * Client for preparing temporary uploads through the SVGMaker API. */ export class UploadClient extends BaseClient { /** @@ -29,17 +24,16 @@ export class UploadClient extends BaseClient { } /** - * Create an upload ticket - * @param params File metadata for the object to be uploaded - * @returns Signed write and read URLs + * Prepare a temporary upload endpoint. + * @param params Original file metadata + * @returns Short-lived multipart upload URL */ - public async createTicket(params: UploadTicketParams): Promise { - this.logger.debug('Creating upload ticket', { + public async prepare(params: UploadPrepareParams): Promise { + this.logger.debug('Preparing temporary upload', { filename: params.filename, - size: params.size, }); - this.validateRequest(params, uploadTicketParamsSchema); + this.validateRequest(params, uploadPrepareParamsSchema); // Raw fetch bypasses the retry/timeout wrapper applied to httpClient.request. const controller = new AbortController(); @@ -47,22 +41,21 @@ export class UploadClient extends BaseClient { let response: globalThis.Response; try { - response = await fetch(`${this.config.baseUrl}/v1/upload/ticket`, { + response = await fetch(`${this.config.baseUrl}/v1/upload/prepare`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.buildAuthHeaders(), }, - body: JSON.stringify({ - filename: params.filename, - content_type: params.contentType, - size: params.size, - }), + body: JSON.stringify({ filename: params.filename }), signal: controller.signal, }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { - throw new APIError(`Upload ticket request timed out after ${this.config.timeout}ms`, 408); + throw new APIError( + `Upload preparation request timed out after ${this.config.timeout}ms`, + 408 + ); } throw error; } finally { @@ -76,12 +69,11 @@ export class UploadClient extends BaseClient { const rawResult = await response.json(); const { data, metadata: responseMetadata } = this.unwrapEnvelope(rawResult); - this.logger.debug('Upload ticket created'); + this.logger.debug('Temporary upload prepared'); return { - putUrl: data.put_url, - fileUrl: data.file_url, - contentType: data.content_type, + uploadUrl: data.upload_url, + expiresIn: data.expires_in, metadata: responseMetadata, }; } diff --git a/src/clients/convert/AIVectorizeClient.ts b/src/clients/convert/AIVectorizeClient.ts index 212b95d..e8d389a 100644 --- a/src/clients/convert/AIVectorizeClient.ts +++ b/src/clients/convert/AIVectorizeClient.ts @@ -12,12 +12,13 @@ const aiVectorizeParamsSchema = z .object({ file: z.union([z.string(), z.instanceof(Buffer), z.instanceof(Readable)]).optional(), imageUrl: z.string().optional(), + uploadId: z.string().optional(), storage: z.boolean().optional(), stream: z.boolean().optional(), svgText: z.boolean().optional(), }) - .refine(data => (data.file ? 1 : 0) + (data.imageUrl ? 1 : 0) === 1, { - message: "Provide exactly one image source: either 'file' or 'imageUrl'", + .refine(data => (data.file ? 1 : 0) + (data.imageUrl ? 1 : 0) + (data.uploadId ? 1 : 0) === 1, { + message: "Provide exactly one image source: 'file', 'imageUrl' or 'uploadId'", }); /** @@ -50,13 +51,7 @@ export class AIVectorizeClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image source. addFileToForm resolves every string against the local - // filesystem, so a URL must never reach it. - if (this.params.imageUrl) { - formData.append('imageUrl', this.params.imageUrl); - } else { - await this.addFileToForm(formData, 'file', this.params.file!); - } + await this.appendImageSource(formData, this.params, 'file'); // Add optional parameters this.appendOptionalParams(formData, this.params as Record, [ @@ -130,13 +125,7 @@ export class AIVectorizeClient extends BaseClient { // Prepare form data const formData = new FormData(); - // Add image source. addFileToForm resolves every string against the - // local filesystem, so a URL must never reach it. - if (client.params.imageUrl) { - formData.append('imageUrl', client.params.imageUrl); - } else { - await this.addFileToForm(formData, 'file', client.params.file!); - } + await this.appendImageSource(formData, client.params, 'file'); // Add storage option if present if (client.params.storage !== undefined) { diff --git a/src/types/api.ts b/src/types/api.ts index d5f04e6..7df0e42 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -101,15 +101,18 @@ export interface GenerateParams { * Edit SVG/Image request parameters */ export interface EditParams { - /** Image file to edit. Provide exactly one of `image`, `imageUrl` or `generationId`. */ + /** Image file to edit. Provide exactly one of `image`, `imageUrl`, `generationId` or `uploadId`. */ image?: string | Buffer | Readable; - /** Publicly reachable https URL the API fetches itself. Provide exactly one of `image`, `imageUrl` or `generationId`. */ + /** Publicly reachable https URL the API fetches itself. Provide exactly one image source. */ imageUrl?: string; - /** Id of a previous generation the API resolves internally. Provide exactly one of `image`, `imageUrl` or `generationId`. */ + /** Id of a previous generation the API resolves internally. Provide exactly one image source. */ generationId?: string; + /** Id returned by the temporary upload endpoint. Provide exactly one image source. */ + uploadId?: string; + /** Optional: Edit instructions as a simple text string */ prompt?: string; @@ -148,12 +151,15 @@ export interface EditParams { * AI Vectorize (Convert Image to SVG) request parameters */ export interface AiVectorizeParams { - /** File to convert to SVG. Provide exactly one of `file` or `imageUrl`. */ + /** File to convert to SVG. Provide exactly one of `file`, `imageUrl` or `uploadId`. */ file?: string | Buffer | Readable; - /** Publicly reachable https URL the API fetches itself. Provide exactly one of `file` or `imageUrl`. */ + /** Publicly reachable https URL the API fetches itself. Provide exactly one image source. */ imageUrl?: string; + /** Id returned by the temporary upload endpoint. Provide exactly one image source. */ + uploadId?: string; + /** Optional: Enable streaming response (default: false) */ stream?: boolean; @@ -177,15 +183,18 @@ export type ConvertParams = AiVectorizeParams; * transparency. Accepts any raster image (PNG, JPEG, WebP, etc.) or SVG. */ export interface RemoveBackgroundParams { - /** Image file to remove the background from. Provide exactly one of `file`, `imageUrl` or `generationId`. */ + /** Image file to remove the background from. Provide exactly one of `file`, `imageUrl`, `generationId` or `uploadId`. */ file?: string | Buffer | Readable; - /** Publicly reachable https URL the API fetches itself. Provide exactly one of `file`, `imageUrl` or `generationId`. */ + /** Publicly reachable https URL the API fetches itself. Provide exactly one image source. */ imageUrl?: string; - /** Id of a previous generation the API resolves internally. Provide exactly one of `file`, `imageUrl` or `generationId`. */ + /** Id of a previous generation the API resolves internally. Provide exactly one image source. */ generationId?: string; + /** Id returned by the temporary upload endpoint. Provide exactly one image source. */ + uploadId?: string; + /** Optional: Enable streaming response (default: false) */ stream?: boolean; @@ -301,27 +310,18 @@ export interface OptimizeSvgResponse { metadata: ResponseMetadata; } -// --- Upload Ticket Types --- +// --- Temporary Upload Types --- -export interface UploadTicketParams { - /** Required: File name. Its extension determines the content type; the stored object name is generated server-side */ +export interface UploadPrepareParams { + /** Required: Original file name, used to prepare a temporary upload endpoint. */ filename: string; - /** Required: Size of the file in bytes */ - size: number; - /** Optional: MIME type override, for when the extension is missing or misleading */ - contentType?: string; -} - -export interface UploadTicketResponse { - /** - * Signed URL to PUT the file bytes to. The request must send - * `Content-Type: ` — that value is bound into the signature. - */ - putUrl: string; - /** Signed read URL to pass back as `imageUrl` on a later edit/convert/remove-background call */ - fileUrl: string; - /** The resolved MIME type — send this as the Content-Type header of the PUT */ - contentType: string; +} + +export interface UploadPrepareResponse { + /** Short-lived SVGMaker endpoint that accepts a multipart `file` upload. */ + uploadUrl: string; + /** Number of seconds before the upload endpoint expires. */ + expiresIn: number; /** Response metadata */ metadata: ResponseMetadata; } diff --git a/tests/clients/EditClient.test.ts b/tests/clients/EditClient.test.ts index 069f8d8..bdba632 100644 --- a/tests/clients/EditClient.test.ts +++ b/tests/clients/EditClient.test.ts @@ -751,6 +751,57 @@ describe('EditClient', () => { }); }); + describe('uploadId', () => { + const UPLOAD_ID = 'upl_abc123'; + + it('sends uploadId as a form field without touching the filesystem', async () => { + const originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + + try { + const client = createTestClient(); + mockFetchJsonResponse(createMockEditResponse()); + + await client.edit.configure({ uploadId: UPLOAD_ID, prompt: 'Make it blue' }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + expect(formData.get('image')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + } finally { + fs.existsSync = originalExistsSync; + } + }); + + it('sends uploadId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.edit + .configure({ uploadId: UPLOAD_ID, prompt: 'Make it blue' }) + .stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + }); + + it('rejects uploadId combined with another image source', async () => { + const client = createTestClient(); + + await expect( + client.edit + .configure({ image: testImageBuffer, uploadId: UPLOAD_ID, prompt: 'Make it blue' }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + // ========================================================================== // Authentication // ========================================================================== diff --git a/tests/clients/RemoveBackgroundClient.test.ts b/tests/clients/RemoveBackgroundClient.test.ts index 04d46d4..3171683 100644 --- a/tests/clients/RemoveBackgroundClient.test.ts +++ b/tests/clients/RemoveBackgroundClient.test.ts @@ -240,6 +240,53 @@ describe('RemoveBackgroundClient', () => { }); }); + describe('uploadId', () => { + const UPLOAD_ID = 'upl_abc123'; + + it('sends uploadId as a form field without touching the filesystem', async () => { + const originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + + try { + const client = createTestClient(); + mockFetchJsonResponse(mockRemoveBackgroundData()); + + await client.removeBackground.configure({ uploadId: UPLOAD_ID }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + } finally { + fs.existsSync = originalExistsSync; + } + }); + + it('sends uploadId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.removeBackground.configure({ uploadId: UPLOAD_ID }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + }); + + it('rejects uploadId combined with another image source', async () => { + const client = createTestClient(); + + await expect( + client.removeBackground.configure({ file: testFileBuffer, uploadId: UPLOAD_ID }).execute() + ).rejects.toThrow(ValidationError); + }); + }); + describe('.stream()', () => { it('emits accumulated fields on the complete event', async () => { const client = createTestClient(); diff --git a/tests/clients/UploadClient.test.ts b/tests/clients/UploadClient.test.ts index de5dc1c..8105d85 100644 --- a/tests/clients/UploadClient.test.ts +++ b/tests/clients/UploadClient.test.ts @@ -14,13 +14,12 @@ import { APIError, } from '../../src/errors/CustomErrors'; -const TICKET = { - put_url: 'https://storage.example.com/temp-uploads/user/abc.png?X-Goog-Signature=write', - file_url: 'https://storage.example.com/temp-uploads/user/abc.png?X-Goog-Signature=read', - content_type: 'image/png', +const PREPARED_UPLOAD = { + upload_url: 'https://api.svgmaker.io/v1/upload/signed-temporary-token', + expires_in: 300, }; -const VALID_PARAMS = { filename: 'panda.png', size: 482113 }; +const VALID_PARAMS = { filename: 'panda.png' }; describe('UploadClient', () => { let fetchMock: jest.Mock; @@ -33,30 +32,26 @@ describe('UploadClient', () => { cleanupMocks(); }); - describe('.createTicket()', () => { - it('sends a JSON POST to /v1/upload/ticket with snake_case fields', async () => { + describe('.prepare()', () => { + it('sends a JSON POST to /v1/upload/prepare', async () => { const client = createTestClient(); - mockFetchJsonResponse(TICKET); + mockFetchJsonResponse(PREPARED_UPLOAD); - await client.upload.createTicket(VALID_PARAMS); + await client.upload.prepare(VALID_PARAMS); const [url, options] = fetchMock.mock.calls[0]; - expect(url).toContain('/v1/upload/ticket'); + expect(url).toContain('/v1/upload/prepare'); expect(options.method).toBe('POST'); expect(options.headers['Content-Type']).toBe('application/json'); expect(options.headers['x-api-key']).toBe('test-api-key-123'); - expect(JSON.parse(options.body)).toEqual({ - filename: 'panda.png', - content_type: undefined, - size: 482113, - }); + expect(JSON.parse(options.body)).toEqual({ filename: 'panda.png' }); }); it('passes an abort signal so a hung request cannot wedge the caller', async () => { const client = createTestClient(); - mockFetchJsonResponse(TICKET); + mockFetchJsonResponse(PREPARED_UPLOAD); - await client.upload.createTicket(VALID_PARAMS); + await client.upload.prepare(VALID_PARAMS); const [, options] = fetchMock.mock.calls[0]; expect(options.signal).toBeInstanceOf(AbortSignal); @@ -64,68 +59,35 @@ describe('UploadClient', () => { it('maps the snake_case envelope to camelCase', async () => { const client = createTestClient(); - mockFetchJsonResponse(TICKET); + mockFetchJsonResponse(PREPARED_UPLOAD); - const result = await client.upload.createTicket(VALID_PARAMS); + const result = await client.upload.prepare(VALID_PARAMS); - expect(result.putUrl).toBe(TICKET.put_url); - expect(result.fileUrl).toBe(TICKET.file_url); - expect(result.contentType).toBe(TICKET.content_type); + expect(result.uploadUrl).toBe(PREPARED_UPLOAD.upload_url); + expect(result.expiresIn).toBe(PREPARED_UPLOAD.expires_in); expect(result.metadata.requestId).toBe('test-req-id'); }); it('sends a Bearer token when an access token is configured', async () => { const client = createOAuthTestClient(); - mockFetchJsonResponse(TICKET); + mockFetchJsonResponse(PREPARED_UPLOAD); - await client.upload.createTicket(VALID_PARAMS); + await client.upload.prepare(VALID_PARAMS); const [, options] = fetchMock.mock.calls[0]; expect(options.headers.Authorization).toBe(`Bearer ${TEST_ACCESS_TOKEN}`); expect(options.headers['x-api-key']).toBeUndefined(); }); - - it('forwards an explicit contentType override', async () => { - const client = createTestClient(); - mockFetchJsonResponse(TICKET); - - await client.upload.createTicket({ filename: 'blob', size: 10, contentType: 'image/png' }); - - const body = JSON.parse(fetchMock.mock.calls[0][1].body); - expect(body.filename).toBe('blob'); - expect(body.content_type).toBe('image/png'); - }); }); describe('validation', () => { it('throws ValidationError when filename is missing or empty', async () => { const client = createTestClient(); - await expect(client.upload.createTicket({ filename: '', size: 10 })).rejects.toThrow( - ValidationError - ); - await expect( - client.upload.createTicket({ size: 10 } as unknown as { filename: string; size: number }) - ).rejects.toThrow(ValidationError); - }); - - it('throws ValidationError when an explicit contentType is empty', async () => { - const client = createTestClient(); - - await expect( - client.upload.createTicket({ filename: 'panda.png', size: 10, contentType: '' }) - ).rejects.toThrow(ValidationError); - }); - - it('throws ValidationError when size is not a positive integer', async () => { - const client = createTestClient(); - - await expect(client.upload.createTicket({ filename: 'panda.png', size: 0 })).rejects.toThrow( + await expect(client.upload.prepare({ filename: '' })).rejects.toThrow(ValidationError); + await expect(client.upload.prepare({} as unknown as { filename: string })).rejects.toThrow( ValidationError ); - await expect( - client.upload.createTicket({ filename: 'panda.png', size: 1.5 }) - ).rejects.toThrow(ValidationError); }); }); @@ -134,21 +96,21 @@ describe('UploadClient', () => { const client = createTestClient(); mockFetchErrorResponse('INVALID_API_KEY', 401); - await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(AuthError); + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(AuthError); }); it('throws RateLimitError on RATE_LIMIT_EXCEEDED', async () => { const client = createTestClient(); mockFetchErrorResponse('RATE_LIMIT_EXCEEDED', 429); - await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(RateLimitError); + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(RateLimitError); }); it('throws APIError on VALIDATION_ERROR from the API', async () => { const client = createTestClient(); - mockFetchErrorResponse('VALIDATION_ERROR', 400, 'size exceeds the 25MB limit.'); + mockFetchErrorResponse('VALIDATION_ERROR', 400, 'filename is invalid.'); - await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(APIError); + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(APIError); }); it('throws a 408 APIError when the request is aborted', async () => { @@ -157,14 +119,14 @@ describe('UploadClient', () => { abortError.name = 'AbortError'; fetchMock.mockRejectedValue(abortError); - await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow(/timed out/); + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow(/timed out/); }); it('rethrows non-abort network errors unchanged', async () => { const client = createTestClient(); fetchMock.mockRejectedValue(new Error('ECONNREFUSED')); - await expect(client.upload.createTicket(VALID_PARAMS)).rejects.toThrow('ECONNREFUSED'); + await expect(client.upload.prepare(VALID_PARAMS)).rejects.toThrow('ECONNREFUSED'); }); }); }); diff --git a/tests/clients/convert/AIVectorizeClient.test.ts b/tests/clients/convert/AIVectorizeClient.test.ts index 63181f6..2e33a3c 100644 --- a/tests/clients/convert/AIVectorizeClient.test.ts +++ b/tests/clients/convert/AIVectorizeClient.test.ts @@ -463,6 +463,55 @@ describe('AIVectorizeClient', () => { }); }); + describe('uploadId', () => { + const UPLOAD_ID = 'upl_abc123'; + + it('sends uploadId as a form field without touching the filesystem', async () => { + const originalExistsSync = fs.existsSync; + fs.existsSync = jest.fn().mockReturnValue(false); + + try { + const client = createTestClient(); + mockFetchJsonResponse(createMockAiVectorizeResponse()); + + await client.convert.aiVectorize.configure({ uploadId: UPLOAD_ID }).execute(); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + expect(formData.get('file')).toBeNull(); + expect(fs.existsSync).not.toHaveBeenCalled(); + } finally { + fs.existsSync = originalExistsSync; + } + }); + + it('sends uploadId on the streaming path', async () => { + const client = createTestClient(); + mockFetchStreamResponse([{ status: 'complete', message: 'done' }]); + + const stream = client.convert.aiVectorize.configure({ uploadId: UPLOAD_ID }).stream(); + + await new Promise((resolve, reject) => { + stream.on('data', () => {}); + stream.on('end', resolve); + stream.on('error', reject); + }); + + const formData = fetchMock.mock.calls[0][1].body as FormData; + expect(formData.get('uploadId')).toBe(UPLOAD_ID); + }); + + it('rejects uploadId combined with another image source', async () => { + const client = createTestClient(); + + await expect( + client.convert.aiVectorize + .configure({ file: testFileBuffer, uploadId: UPLOAD_ID }) + .execute() + ).rejects.toThrow(ValidationError); + }); + }); + // ========================================================================== // Authentication // ========================================================================== From 20507e9c1facd0b46005cbed44fc7ae9bde45e8b Mon Sep 17 00:00:00 2001 From: vishal Date: Tue, 11 Aug 2026 09:15:07 +0530 Subject: [PATCH 3/3] chore: bump version to 1.3.0 --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9428a..64f1047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.3.0] - 2026-08-11 + ### Added - `imageUrl` on `EditParams`, `AiVectorizeParams` and `RemoveBackgroundParams`, and `generationId` on edit and remove-background, so an image can be supplied without reading a local file - `uploadId` on the same three, accepting an id from the temporary upload endpoint diff --git a/package-lock.json b/package-lock.json index 2a0ac3b..5ff76dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@genwave/svgmaker-sdk", - "version": "1.2.0", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@genwave/svgmaker-sdk", - "version": "1.2.0", + "version": "1.3.0", "license": "MIT", "dependencies": { "async-retry": "^1.3.3", diff --git a/package.json b/package.json index a6c1330..32b5bb3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@genwave/svgmaker-sdk", - "version": "1.2.0", + "version": "1.3.0", "description": "Official Node.js SDK for SVGMaker API", "main": "./dist/cjs/index.js", "module": "./dist/esm/index.js",