Skip to content
Open
32 changes: 32 additions & 0 deletions packages/abyssale/cache-banner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { AbyssaleBanner } from './schema/database';

type CacheCtx = {
db: {
banners?: {
upsertByEntityId: (
entityId: string,
data: AbyssaleBanner,
) => Promise<{ id?: string } | null | undefined>;
};
};
};

/**
* Mirrors a generated visual into the plugin cache. Best-effort: a caching
* failure must never fail an otherwise successful generation or webhook.
* Returns the corsair entity id (empty string when caching is unavailable).
*/
export async function cacheBanner(
ctx: CacheCtx,
banner: AbyssaleBanner,
): Promise<string> {
if (!ctx.db.banners) return '';

try {
const entity = await ctx.db.banners.upsertByEntityId(banner.id, banner);
return entity?.id || '';
} catch (error) {
console.warn(`[abyssale] failed to cache banner ${banner.id}:`, error);
return '';
}
}
201 changes: 200 additions & 1 deletion packages/abyssale/endpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,16 @@ const mockRequest = request as jest.MockedFunction<typeof request>;
describe('Abyssale Plugin API', () => {
const apiKey = 'test-api-key';
const plugin = abyssale({ key: apiKey }) as any;
const ctx = { key: apiKey } as any;
const upsert = jest.fn();
const ctx = {
key: apiKey,
db: { banners: { upsertByEntityId: upsert } },
} as any;

beforeEach(() => {
mockRequest.mockReset();
upsert.mockReset();
upsert.mockResolvedValue({ id: 'corsair-entity-1' });
});

describe('createProject', () => {
Expand Down Expand Up @@ -144,6 +150,199 @@ describe('Abyssale Plugin API', () => {
});
});

describe('generation.image', () => {
const designId = '5978e8d9-ab34-4735-a2cb-fe95c2c56251';

it('sends POST /banner-builder/{designId}/generate without the path param in the body', async () => {
const mockResponse = {
id: 'b3f1a6ea-0d47-4e29-9c02-8f7f5f4e6a01',
version: 1,
file: {
type: 'jpeg',
url: 'https://cdn.abyssale.com/banner.jpeg',
filename: 'banner.jpeg',
},
format: { id: 'facebook-feed', width: 1200, height: 628 },
template: { id: designId, name: 'Summer campaign' },
};
mockRequest.mockResolvedValueOnce(mockResponse);

const result = await plugin.endpoints.generation.image(ctx, {
designId,
template_format_name: 'facebook-feed',
image_file_type: 'png',
file_compression_level: 90,
elements: {
text_title: { payload: 'Hello', color: '#FF0000' },
},
});

expect(mockRequest).toHaveBeenCalledWith(
expect.objectContaining({
BASE: 'https://api.abyssale.com',
HEADERS: expect.objectContaining({
'x-api-key': apiKey,
}),
}),
expect.objectContaining({
method: 'POST',
url: `banner-builder/${designId}/generate`,
body: {
template_format_name: 'facebook-feed',
image_file_type: 'png',
file_compression_level: 90,
elements: {
text_title: { payload: 'Hello', color: '#FF0000' },
},
},
}),
);
expect(result).toEqual(mockResponse);
expect(upsert).toHaveBeenCalledWith(
mockResponse.id,
expect.objectContaining({ id: mockResponse.id }),
);
});

it('rejects a non-uuid design id before calling the API', async () => {
await expect(
plugin.endpoints.generation.image(ctx, { designId: 'not-a-uuid' }),
).rejects.toThrow();
expect(mockRequest).not.toHaveBeenCalled();
});

it('rejects an unsupported image_file_type before calling the API', async () => {
await expect(
plugin.endpoints.generation.image(ctx, {
designId,
image_file_type: 'mp4',
}),
).rejects.toThrow();
expect(mockRequest).not.toHaveBeenCalled();
});
});

describe('generation.batch', () => {
const designId = '46d22c62-d134-44d3-a040-138e4ea9ea08';

it('sends POST /async/banner-builder/{designId}/generate', async () => {
const requestId = 'df75afa8-5a77-4e03-aeef-6d1b6dd0580a';
mockRequest.mockResolvedValueOnce({
generation_request_id: requestId,
});

const result = await plugin.endpoints.generation.batch(ctx, {
designId,
template_format_names: ['facebook-feed', 'instagram-post'],
callback_url: 'https://webhook.example.com/abyssale',
gif: { max_fps: 9 },
});

expect(mockRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
url: `async/banner-builder/${designId}/generate`,
body: {
template_format_names: ['facebook-feed', 'instagram-post'],
callback_url: 'https://webhook.example.com/abyssale',
gif: { max_fps: 9 },
},
}),
);
expect(result).toEqual({ generation_request_id: requestId });
});

it('rejects a gif fps outside the documented 2-9 range', async () => {
await expect(
plugin.endpoints.generation.batch(ctx, {
designId,
gif: { max_fps: 30 as never },
}),
).rejects.toThrow();
expect(mockRequest).not.toHaveBeenCalled();
});
});

describe('generation.status', () => {
const requestId = '497f6eca-6276-4993-bfeb-53cbbbba6f08';

it('polls GET /generation-request/{id} while not finalized', async () => {
mockRequest.mockResolvedValueOnce({
is_finalized: false,
id: requestId,
banners: [],
errors: [],
});

const result = await plugin.endpoints.generation.status(ctx, {
generationRequestId: requestId,
});

expect(mockRequest).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'GET',
url: `generation-request/${requestId}`,
}),
);
expect(result.is_finalized).toBe(false);
expect(upsert).not.toHaveBeenCalled();
});

it('returns per-format errors alongside finished banners', async () => {
const banner = {
id: 'ec3a9fcd-f209-4077-b5ea-037d4bdfa9f2',
file: { type: 'jpeg', url: 'https://cdn.abyssale.com/a.jpeg' },
format: { id: 'facebook', width: 1200, height: 628 },
};
mockRequest.mockResolvedValueOnce({
is_finalized: true,
id: requestId,
banners: [banner],
errors: [
{
template_format_name: 'instagram-story',
reason: 'The text cannot fit within the defined space.',
},
],
});

const result = await plugin.endpoints.generation.status(ctx, {
generationRequestId: requestId,
});

expect(result.banners).toEqual([banner]);
expect(result.errors).toHaveLength(1);
expect(upsert).toHaveBeenCalledWith(
banner.id,
expect.objectContaining({ id: banner.id }),
);
});

it('accepts a finalized payload that omits errors and caches banners', async () => {
const banner = {
id: 'ec3a9fcd-f209-4077-b5ea-037d4bdfa9f2',
file: { type: 'jpeg', url: 'https://cdn.abyssale.com/a.jpeg' },
};
mockRequest.mockResolvedValueOnce({
is_finalized: true,
id: requestId,
banners: [banner],
});

const result = await plugin.endpoints.generation.status(ctx, {
generationRequestId: requestId,
});

expect(result.errors).toEqual([]);
expect(upsert).toHaveBeenCalledWith(
banner.id,
expect.objectContaining({ id: banner.id }),
);
});
});

describe('error handling', () => {
it('maps ApiError to AbyssaleAPIError with cause', async () => {
const apiError = new ApiError(
Expand Down
111 changes: 111 additions & 0 deletions packages/abyssale/endpoints/generation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { logEventFromContext } from 'corsair/core';
import type { AbyssaleEndpoints } from '..';
import { makeAbyssaleRequest } from '../client';
import { cacheEntities, parseInput, parseOutput } from './shared';
import type {
AbyssaleEndpointOutputs,
GenerateBatchInput,
GenerateImageInput,
} from './types';

/**
* Synchronous generation — one image per call, static designs only. The render
* is hard-capped at 10 s provider-side; heavier output (video, GIF, HTML5,
* print PDF) belongs to `generateBatch`.
*/
export const generateImage: AbyssaleEndpoints['generateImage'] = async (
ctx,
input,
) => {
const args = parseInput('generateImage', input);
const { designId, ...body } = args as GenerateImageInput & {
designId: string;
};

const response = await makeAbyssaleRequest<
AbyssaleEndpointOutputs['generateImage']
>(`banner-builder/${designId}/generate`, ctx.key, {
method: 'POST',
body: body as Record<string, unknown>,
});

const result = parseOutput('generateImage', response);

await cacheEntities(ctx, 'banners', [result]);

await logEventFromContext(
ctx,
'abyssale.generation.image',
{ design_id: designId },
'completed',
);
return result;
};

/**
* Asynchronous generation — many formats in one call. Returns a
* `generation_request_id` immediately; results arrive via the `NEW_BANNER_BATCH`
* webhook or by polling `getGenerationRequest`.
*/
export const generateBatch: AbyssaleEndpoints['generateBatch'] = async (
ctx,
input,
) => {
const args = parseInput('generateBatch', input);
const { designId, ...body } = args as GenerateBatchInput & {
designId: string;
};

const response = await makeAbyssaleRequest<
AbyssaleEndpointOutputs['generateBatch']
>(`async/banner-builder/${designId}/generate`, ctx.key, {
method: 'POST',
body: body as Record<string, unknown>,
});

const result = parseOutput('generateBatch', response);

await logEventFromContext(
ctx,
'abyssale.generation.batch',
{
design_id: designId,
generation_request_id: result.generation_request_id,
},
'completed',
);
return result;
};

/**
* Polls an async generation request (`202` + `is_finalized: false` while it
* runs, `200` + `is_finalized: true` when complete). Banners are cached only
* once finalized so partial results never masquerade as final ones.
*/
export const getGenerationRequest: AbyssaleEndpoints['getGenerationRequest'] =
async (ctx, input) => {
const args = parseInput('getGenerationRequest', input);

const response = await makeAbyssaleRequest<
AbyssaleEndpointOutputs['getGenerationRequest']
>(`generation-request/${args.generationRequestId}`, ctx.key, {
method: 'GET',
});

const result = parseOutput('getGenerationRequest', response);

if (result.is_finalized && result.banners.length > 0) {
await cacheEntities(ctx, 'banners', result.banners);
}

await logEventFromContext(
ctx,
'abyssale.generation.status',
{
generation_request_id: args.generationRequestId,
is_finalized: result.is_finalized,
},
'completed',
);
return result;
};
1 change: 1 addition & 0 deletions packages/abyssale/endpoints/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * as Auth from './auth';
export * as Designs from './designs';
export * as Fonts from './fonts';
export * as Generation from './generation';
export * as Projects from './projects';
export * from './types';
2 changes: 1 addition & 1 deletion packages/abyssale/endpoints/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ type EntityClient = {
*/
export async function cacheEntities(
ctx: AbyssaleContext,
entity: 'projects' | 'designs' | 'fonts',
entity: 'projects' | 'designs' | 'fonts' | 'banners',
items: ReadonlyArray<{ id?: string }>,
): Promise<void> {
const db = ctx.db as unknown as
Expand Down
Loading
Loading