From 0271ff9ed23c6a8bab1c2055a1f6d81f776cbff3 Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 10:08:50 +0530 Subject: [PATCH 01/11] feat(bannerbear): add bannerbear plugin --- packages/bannerbear/client.ts | 58 ++ packages/bannerbear/endpoints.test.ts | 119 +++ packages/bannerbear/endpoints/account.ts | 39 + packages/bannerbear/endpoints/animations.ts | 46 ++ packages/bannerbear/endpoints/collections.ts | 27 + packages/bannerbear/endpoints/images.ts | 40 + packages/bannerbear/endpoints/index.ts | 62 ++ packages/bannerbear/endpoints/misc.ts | 47 ++ packages/bannerbear/endpoints/projects.ts | 65 ++ packages/bannerbear/endpoints/screenshots.ts | 43 + packages/bannerbear/endpoints/signed-urls.ts | 42 + .../bannerbear/endpoints/template-sets.ts | 88 +++ packages/bannerbear/endpoints/templates.ts | 102 +++ packages/bannerbear/endpoints/types.ts | 522 +++++++++++++ packages/bannerbear/endpoints/videos.ts | 66 ++ packages/bannerbear/endpoints/webhooks-api.ts | 56 ++ packages/bannerbear/endpoints/workflows.ts | 92 +++ packages/bannerbear/error-handlers.ts | 31 + packages/bannerbear/index.ts | 736 ++++++++++++++++++ packages/bannerbear/jest.config.cjs | 55 ++ packages/bannerbear/package.json | 44 ++ packages/bannerbear/schema.test.ts | 20 + packages/bannerbear/schema/database.ts | 216 +++++ packages/bannerbear/schema/index.ts | 44 ++ packages/bannerbear/tsconfig.json | 20 + packages/bannerbear/tsup.config.ts | 15 + .../bannerbear/webhooks/image-completed.ts | 35 + packages/bannerbear/webhooks/index.ts | 13 + .../bannerbear/webhooks/tenant-matcher.ts | 11 + packages/bannerbear/webhooks/types.ts | 113 +++ .../bannerbear/webhooks/video-completed.ts | 35 + 31 files changed, 2902 insertions(+) create mode 100644 packages/bannerbear/client.ts create mode 100644 packages/bannerbear/endpoints.test.ts create mode 100644 packages/bannerbear/endpoints/account.ts create mode 100644 packages/bannerbear/endpoints/animations.ts create mode 100644 packages/bannerbear/endpoints/collections.ts create mode 100644 packages/bannerbear/endpoints/images.ts create mode 100644 packages/bannerbear/endpoints/index.ts create mode 100644 packages/bannerbear/endpoints/misc.ts create mode 100644 packages/bannerbear/endpoints/projects.ts create mode 100644 packages/bannerbear/endpoints/screenshots.ts create mode 100644 packages/bannerbear/endpoints/signed-urls.ts create mode 100644 packages/bannerbear/endpoints/template-sets.ts create mode 100644 packages/bannerbear/endpoints/templates.ts create mode 100644 packages/bannerbear/endpoints/types.ts create mode 100644 packages/bannerbear/endpoints/videos.ts create mode 100644 packages/bannerbear/endpoints/webhooks-api.ts create mode 100644 packages/bannerbear/endpoints/workflows.ts create mode 100644 packages/bannerbear/error-handlers.ts create mode 100644 packages/bannerbear/index.ts create mode 100644 packages/bannerbear/jest.config.cjs create mode 100644 packages/bannerbear/package.json create mode 100644 packages/bannerbear/schema.test.ts create mode 100644 packages/bannerbear/schema/database.ts create mode 100644 packages/bannerbear/schema/index.ts create mode 100644 packages/bannerbear/tsconfig.json create mode 100644 packages/bannerbear/tsup.config.ts create mode 100644 packages/bannerbear/webhooks/image-completed.ts create mode 100644 packages/bannerbear/webhooks/index.ts create mode 100644 packages/bannerbear/webhooks/tenant-matcher.ts create mode 100644 packages/bannerbear/webhooks/types.ts create mode 100644 packages/bannerbear/webhooks/video-completed.ts diff --git a/packages/bannerbear/client.ts b/packages/bannerbear/client.ts new file mode 100644 index 000000000..7c00dc527 --- /dev/null +++ b/packages/bannerbear/client.ts @@ -0,0 +1,58 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { request } from 'corsair/http'; + +export class BannerbearAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'BannerbearAPIError'; + } +} + +const BANNERBEAR_API_BASE = 'https://api.bannerbear.com'; + +export async function makeBannerbearRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + } = {}, +): Promise { + const { method = 'GET', body, query } = options; + + const config: OpenAPIConfig = { + BASE: BANNERBEAR_API_BASE, + VERSION: '5.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + if (error instanceof Error) { + throw new BannerbearAPIError(error.message); + } + throw new BannerbearAPIError('Unknown error'); + } +} diff --git a/packages/bannerbear/endpoints.test.ts b/packages/bannerbear/endpoints.test.ts new file mode 100644 index 000000000..e3ca37b63 --- /dev/null +++ b/packages/bannerbear/endpoints.test.ts @@ -0,0 +1,119 @@ +import type { RawWebhookRequest } from 'corsair/core'; +import { bannerbear, bannerbearEndpointSchemas } from './index'; +import { + createBannerbearImageCompletedMatch, + createBannerbearVideoCompletedMatch, +} from './webhooks/types'; + +describe('Bannerbear plugin registry', () => { + const plugin = bannerbear(); + + const endpointTree = plugin.endpoints as unknown as Record< + string, + Record + >; + + function nestedPaths(): string[] { + const paths: string[] = []; + for (const [group, leaves] of Object.entries(endpointTree)) { + for (const leaf of Object.keys(leaves)) { + paths.push(`${group}.${leaf}`); + } + } + return paths.sort(); + } + + it('registers all 38 operations in the endpoint tree', () => { + const paths = nestedPaths(); + expect(paths).toHaveLength(38); + }); + + it('keeps the endpoint tree, schemas, and metadata in lockstep', () => { + const paths = nestedPaths(); + expect(Object.keys(bannerbearEndpointSchemas).sort()).toEqual(paths); + expect( + Object.keys(plugin.endpointMeta as Record).sort(), + ).toEqual(paths); + }); + + it('defines input and output schemas for every endpoint', () => { + for (const [path, schema] of Object.entries(bannerbearEndpointSchemas)) { + expect(schema.input).toBeDefined(); + expect(schema.output).toBeDefined(); + } + }); + + it('defines metadata with riskLevel and description for every endpoint', () => { + const metaMap = (plugin.endpointMeta ?? {}) as Record< + string, + { riskLevel?: string; description?: string } + >; + for (const [_path, meta] of Object.entries(metaMap)) { + expect(['read', 'write']).toContain(meta.riskLevel); + expect(typeof meta.description).toBe('string'); + expect(meta.description?.length).toBeGreaterThan(0); + } + }); +}); + +describe('Bannerbear webhooks', () => { + it('matches image completed webhook payloads', () => { + const matcher = createBannerbearImageCompletedMatch(); + + const validImagePayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'completed', + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }), + }; + + const pendingPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'pending', + files: null, + }), + }; + + const videoPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'vid_123', + status: 'completed', + video_url: 'https://cdn.bannerbear.com/sample.mp4', + }), + }; + + expect(matcher(validImagePayload)).toBe(true); + expect(matcher(pendingPayload)).toBe(false); + expect(matcher(videoPayload)).toBe(false); + }); + + it('matches video completed webhook payloads', () => { + const matcher = createBannerbearVideoCompletedMatch(); + + const validVideoPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'vid_123', + status: 'completed', + video_url: 'https://cdn.bannerbear.com/sample.mp4', + }), + }; + + const imagePayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'completed', + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }), + }; + + expect(matcher(validVideoPayload)).toBe(true); + expect(matcher(imagePayload)).toBe(false); + }); +}); diff --git a/packages/bannerbear/endpoints/account.ts b/packages/bannerbear/endpoints/account.ts new file mode 100644 index 000000000..09a48e5c5 --- /dev/null +++ b/packages/bannerbear/endpoints/account.ts @@ -0,0 +1,39 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const getAccountInfo: BannerbearEndpoints['getAccountInfo'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAccountInfo'] + >('/v5/account', ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.account.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const getAuth: BannerbearEndpoints['getAuth'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAuth'] + >('/v5/account', ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.auth.get', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/animations.ts b/packages/bannerbear/endpoints/animations.ts new file mode 100644 index 000000000..4c9ab6a4a --- /dev/null +++ b/packages/bannerbear/endpoints/animations.ts @@ -0,0 +1,46 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listAnimatedGifs'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listAnimatedGifs'] + >('/v5/animated_gifs', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animated_gifs.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getAnimatedGif'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAnimatedGif'] + >(`/v5/animated_gifs/${input.uid}`, ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animated_gifs.get', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/collections.ts b/packages/bannerbear/endpoints/collections.ts new file mode 100644 index 000000000..926c0b0e1 --- /dev/null +++ b/packages/bannerbear/endpoints/collections.ts @@ -0,0 +1,27 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listCollections'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listCollections'] + >('/v5/collections', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.collections.list', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/images.ts b/packages/bannerbear/endpoints/images.ts new file mode 100644 index 000000000..9539bc00c --- /dev/null +++ b/packages/bannerbear/endpoints/images.ts @@ -0,0 +1,40 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listImages'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listImages'] + >('/v5/images', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.images.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getImage'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getImage'] + >(`/v5/images/${input.uid}`, ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.images.get', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/index.ts b/packages/bannerbear/endpoints/index.ts new file mode 100644 index 000000000..4ba6914de --- /dev/null +++ b/packages/bannerbear/endpoints/index.ts @@ -0,0 +1,62 @@ +import { getAccountInfo, getAuth } from './account'; +import * as Animations from './animations'; +import * as Collections from './collections'; +import * as Images from './images'; +import { getFonts, joinPdfs, listEffects } from './misc'; +import * as Projects from './projects'; +import * as Screenshots from './screenshots'; +import { createSignedBase, getSignedBases } from './signed-urls'; +import * as TemplateSets from './template-sets'; +import * as Templates from './templates'; +import { createVideoTemplate, listVideos, listVideoTemplates } from './videos'; +import * as WebhooksApi from './webhooks-api'; +import { + createWorkflowRun, + getWorkflow, + getWorkflowRun, + listWorkflowRuns, + listWorkflows, +} from './workflows'; + +export const Account = { + getAccountInfo, + getAuth, +}; + +export { Projects }; +export { Templates }; +export { TemplateSets }; +export { Images }; + +export const Videos = { + listVideos, + listVideoTemplates, + createVideoTemplate, +}; + +export { Animations }; +export { Collections }; +export { Screenshots }; + +export const SignedUrls = { + getSignedBases, + createSignedBase, +}; + +export { WebhooksApi }; + +export const Misc = { + getFonts, + listEffects, + joinPdfs, +}; + +export const Workflows = { + listWorkflows, + getWorkflow, + createWorkflowRun, + getWorkflowRun, + listWorkflowRuns, +}; + +export * from './types'; diff --git a/packages/bannerbear/endpoints/misc.ts b/packages/bannerbear/endpoints/misc.ts new file mode 100644 index 000000000..e4af8c7cf --- /dev/null +++ b/packages/bannerbear/endpoints/misc.ts @@ -0,0 +1,47 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const getFonts: BannerbearEndpoints['getFonts'] = async ( + ctx, + _input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getFonts'] + >('/v5/fonts', ctx.key, { method: 'GET' }); + await logEventFromContext(ctx, 'bannerbear.fonts.get', {}, 'completed'); + return response; +}; + +export const listEffects: BannerbearEndpoints['listEffects'] = async ( + ctx, + _input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listEffects'] + >('/v5/effects', ctx.key, { method: 'GET' }); + await logEventFromContext(ctx, 'bannerbear.effects.list', {}, 'completed'); + return response; +}; + +export const joinPdfs: BannerbearEndpoints['joinPdfs'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['joinPdfs'] + >('/v5/pdfs', ctx.key, { + method: 'POST', + body: { + pdf_urls: input.pdf_urls, + project_id: input.project_id, + webhook_url: input.webhook_url, + metadata: input.metadata, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.pdfs.join', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/projects.ts b/packages/bannerbear/endpoints/projects.ts new file mode 100644 index 000000000..c8fac4849 --- /dev/null +++ b/packages/bannerbear/endpoints/projects.ts @@ -0,0 +1,65 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listProjects'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listProjects'] + >('/v5/projects', ctx.key, { method: 'GET', query: { page: input.page } }); + await logEventFromContext( + ctx, + 'bannerbear.projects.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getProject'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getProject'] + >(`/v5/projects/${input.uid}`, ctx.key, { method: 'GET' }); + await logEventFromContext( + ctx, + 'bannerbear.projects.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createProject'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createProject'] + >('/v5/projects', ctx.key, { method: 'POST', body: { name: input.name } }); + await logEventFromContext( + ctx, + 'bannerbear.projects.create', + { ...input }, + 'completed', + ); + return response; +}; + +export const hydrate: BannerbearEndpoints['hydrateProject'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['hydrateProject'] + >(`/v5/projects/${input.uid}/hydrate`, ctx.key, { + method: 'POST', + body: { source_project: input.source_project }, + }); + await logEventFromContext( + ctx, + 'bannerbear.projects.hydrate', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/screenshots.ts b/packages/bannerbear/endpoints/screenshots.ts new file mode 100644 index 000000000..0607ee17a --- /dev/null +++ b/packages/bannerbear/endpoints/screenshots.ts @@ -0,0 +1,43 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listScreenshots'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listScreenshots'] + >('/v5/screenshots', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.screenshots.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getScreenshot'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getScreenshot'] + >(`/v5/screenshots/${input.uid}`, ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.screenshots.get', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/signed-urls.ts b/packages/bannerbear/endpoints/signed-urls.ts new file mode 100644 index 000000000..abef57b3e --- /dev/null +++ b/packages/bannerbear/endpoints/signed-urls.ts @@ -0,0 +1,42 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const getSignedBases: BannerbearEndpoints['getSignedBases'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getSignedBases'] + >(`/v5/templates/${input.uid}/signed_bases`, ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.signed_bases.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const createSignedBase: BannerbearEndpoints['createSignedBase'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createSignedBase'] + >(`/v5/templates/${input.uid}/signed_bases`, ctx.key, { + method: 'POST', + body: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.signed_bases.create', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/template-sets.ts b/packages/bannerbear/endpoints/template-sets.ts new file mode 100644 index 000000000..dcf695d10 --- /dev/null +++ b/packages/bannerbear/endpoints/template-sets.ts @@ -0,0 +1,88 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listTemplateSets'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listTemplateSets'] + >('/v5/template_sets', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.template_sets.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getTemplateSet'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getTemplateSet'] + >(`/v5/template_sets/${input.uid}`, ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.template_sets.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createTemplateSet'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createTemplateSet'] + >('/v5/template_sets', ctx.key, { + method: 'POST', + body: { + name: input.name, + templates: input.templates, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.template_sets.create', + { ...input }, + 'completed', + ); + return response; +}; + +export const update: BannerbearEndpoints['updateTemplateSet'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['updateTemplateSet'] + >(`/v5/template_sets/${input.uid}`, ctx.key, { + method: 'PATCH', + body: { templates: input.templates, project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.template_sets.update', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/templates.ts b/packages/bannerbear/endpoints/templates.ts new file mode 100644 index 000000000..b19d3b9e6 --- /dev/null +++ b/packages/bannerbear/endpoints/templates.ts @@ -0,0 +1,102 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listTemplates'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listTemplates'] + >('/v5/templates', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getTemplate'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getTemplate'] + >(`/v5/templates/${input.uid}`, ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createTemplate'] = async ( + ctx, + input, +) => { + const { project_id, ...body } = input; + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createTemplate'] + >('/v5/templates', ctx.key, { + method: 'POST', + body: { ...body, project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.create', + { ...input }, + 'completed', + ); + return response; +}; + +export const deleteTemplate: BannerbearEndpoints['deleteTemplate'] = async ( + ctx, + input, +) => { + await makeBannerbearRequest(`/v5/templates/${input.uid}`, ctx.key, { + method: 'DELETE', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.delete', + { ...input }, + 'completed', + ); + return { success: true }; +}; + +export const importTemplate: BannerbearEndpoints['importTemplate'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['importTemplate'] + >('/v5/templates/import', ctx.key, { + method: 'POST', + body: { + publication_id: input.publication_id, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.templates.import', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/types.ts b/packages/bannerbear/endpoints/types.ts new file mode 100644 index 000000000..6a28c8efe --- /dev/null +++ b/packages/bannerbear/endpoints/types.ts @@ -0,0 +1,522 @@ +import { z } from 'zod'; +import { + BannerbearAccount, + BannerbearAnimatedGif, + BannerbearCollection, + BannerbearEffect, + BannerbearFont, + BannerbearImage, + BannerbearPdfJoin, + BannerbearProject, + BannerbearScreenshot, + BannerbearSignedBase, + BannerbearTemplate, + BannerbearTemplateSet, + BannerbearVideo, + BannerbearVideoTemplate, + BannerbearWebhookObj, + BannerbearWorkflow, + BannerbearWorkflowRun, +} from '../schema/database'; + +// ─── Pagination helper ─────────────────────────────────────── +const PaginationInput = z.object({ + page: z.number().optional(), + limit: z.number().optional(), + project_id: z.string().optional(), +}); + +// ─── Account ───────────────────────────────────────────────── +const GetAccountInfoInputSchema = z.object({ + project_id: z.string().optional(), +}); +export type GetAccountInfoInput = z.infer; +const GetAccountInfoResponseSchema = BannerbearAccount; +export type GetAccountInfoResponse = z.infer< + typeof GetAccountInfoResponseSchema +>; + +const GetAuthInputSchema = z.object({ + project_id: z.string().optional(), +}); +export type GetAuthInput = z.infer; +const GetAuthResponseSchema = BannerbearAccount; +export type GetAuthResponse = z.infer; + +// ─── Projects ──────────────────────────────────────────────── +const ListProjectsInputSchema = PaginationInput.pick({ page: true }); +export type ListProjectsInput = z.infer; +const ListProjectsResponseSchema = z.array(BannerbearProject); +export type ListProjectsResponse = z.infer; + +const GetProjectInputSchema = z.object({ uid: z.string() }); +export type GetProjectInput = z.infer; +const GetProjectResponseSchema = BannerbearProject; +export type GetProjectResponse = z.infer; + +const CreateProjectInputSchema = z.object({ + name: z.string(), +}); +export type CreateProjectInput = z.infer; +const CreateProjectResponseSchema = BannerbearProject; +export type CreateProjectResponse = z.infer; + +const HydrateProjectInputSchema = z.object({ + uid: z.string(), + source_project: z.string(), +}); +export type HydrateProjectInput = z.infer; +const HydrateProjectResponseSchema = BannerbearProject; +export type HydrateProjectResponse = z.infer< + typeof HydrateProjectResponseSchema +>; + +// ─── Templates ─────────────────────────────────────────────── +const ListTemplatesInputSchema = PaginationInput; +export type ListTemplatesInput = z.infer; +const ListTemplatesResponseSchema = z.array(BannerbearTemplate); +export type ListTemplatesResponse = z.infer; + +const GetTemplateInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type GetTemplateInput = z.infer; +const GetTemplateResponseSchema = BannerbearTemplate; +export type GetTemplateResponse = z.infer; + +const CreateTemplateInputSchema = z.object({ + name: z.string(), + width: z.number().optional(), + height: z.number().optional(), + project_id: z.string().optional(), +}); +export type CreateTemplateInput = z.infer; +const CreateTemplateResponseSchema = BannerbearTemplate; +export type CreateTemplateResponse = z.infer< + typeof CreateTemplateResponseSchema +>; + +const DeleteTemplateInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type DeleteTemplateInput = z.infer; +const DeleteTemplateResponseSchema = z.object({ success: z.boolean() }); +export type DeleteTemplateResponse = z.infer< + typeof DeleteTemplateResponseSchema +>; + +const ImportTemplateInputSchema = z.object({ + publication_id: z.string(), + project_id: z.string().optional(), +}); +export type ImportTemplateInput = z.infer; +const ImportTemplateResponseSchema = BannerbearTemplate; +export type ImportTemplateResponse = z.infer< + typeof ImportTemplateResponseSchema +>; + +// ─── Template Sets ─────────────────────────────────────────── +const ListTemplateSetsInputSchema = PaginationInput; +export type ListTemplateSetsInput = z.infer; +const ListTemplateSetsResponseSchema = z.array(BannerbearTemplateSet); +export type ListTemplateSetsResponse = z.infer< + typeof ListTemplateSetsResponseSchema +>; + +const GetTemplateSetInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type GetTemplateSetInput = z.infer; +const GetTemplateSetResponseSchema = BannerbearTemplateSet; +export type GetTemplateSetResponse = z.infer< + typeof GetTemplateSetResponseSchema +>; + +const CreateTemplateSetInputSchema = z.object({ + name: z.string(), + templates: z.array(z.string()), + project_id: z.string().optional(), +}); +export type CreateTemplateSetInput = z.infer< + typeof CreateTemplateSetInputSchema +>; +const CreateTemplateSetResponseSchema = BannerbearTemplateSet; +export type CreateTemplateSetResponse = z.infer< + typeof CreateTemplateSetResponseSchema +>; + +const UpdateTemplateSetInputSchema = z.object({ + uid: z.string(), + templates: z.array(z.string()), + project_id: z.string().optional(), +}); +export type UpdateTemplateSetInput = z.infer< + typeof UpdateTemplateSetInputSchema +>; +const UpdateTemplateSetResponseSchema = BannerbearTemplateSet; +export type UpdateTemplateSetResponse = z.infer< + typeof UpdateTemplateSetResponseSchema +>; + +// ─── Images ────────────────────────────────────────────────── +const ListImagesInputSchema = PaginationInput; +export type ListImagesInput = z.infer; +const ListImagesResponseSchema = z.array(BannerbearImage); +export type ListImagesResponse = z.infer; + +const GetImageInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type GetImageInput = z.infer; +const GetImageResponseSchema = BannerbearImage; +export type GetImageResponse = z.infer; + +// ─── Videos ────────────────────────────────────────────────── +const ListVideosInputSchema = PaginationInput; +export type ListVideosInput = z.infer; +const ListVideosResponseSchema = z.array(BannerbearVideo); +export type ListVideosResponse = z.infer; + +const ListVideoTemplatesInputSchema = PaginationInput; +export type ListVideoTemplatesInput = z.infer< + typeof ListVideoTemplatesInputSchema +>; +const ListVideoTemplatesResponseSchema = z.array(BannerbearVideoTemplate); +export type ListVideoTemplatesResponse = z.infer< + typeof ListVideoTemplatesResponseSchema +>; + +const CreateVideoTemplateInputSchema = z.object({ + name: z.string(), + template: z.string(), + project_id: z.string().optional(), + input_media_url: z.string().optional(), + frames: z.array(z.record(z.string(), z.unknown())).optional(), + soundtrack_url: z.string().optional(), + transition: z.string().optional(), + transcription: z.record(z.string(), z.unknown()).optional(), +}); +export type CreateVideoTemplateInput = z.infer< + typeof CreateVideoTemplateInputSchema +>; +const CreateVideoTemplateResponseSchema = BannerbearVideoTemplate; +export type CreateVideoTemplateResponse = z.infer< + typeof CreateVideoTemplateResponseSchema +>; + +// ─── Animated GIFs ─────────────────────────────────────────── +const ListAnimatedGifsInputSchema = PaginationInput; +export type ListAnimatedGifsInput = z.infer; +const ListAnimatedGifsResponseSchema = z.array(BannerbearAnimatedGif); +export type ListAnimatedGifsResponse = z.infer< + typeof ListAnimatedGifsResponseSchema +>; + +const GetAnimatedGifInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type GetAnimatedGifInput = z.infer; +const GetAnimatedGifResponseSchema = BannerbearAnimatedGif; +export type GetAnimatedGifResponse = z.infer< + typeof GetAnimatedGifResponseSchema +>; + +// ─── Collections ───────────────────────────────────────────── +const ListCollectionsInputSchema = PaginationInput; +export type ListCollectionsInput = z.infer; +const ListCollectionsResponseSchema = z.array(BannerbearCollection); +export type ListCollectionsResponse = z.infer< + typeof ListCollectionsResponseSchema +>; + +// ─── Screenshots ───────────────────────────────────────────── +const ListScreenshotsInputSchema = PaginationInput; +export type ListScreenshotsInput = z.infer; +const ListScreenshotsResponseSchema = z.array(BannerbearScreenshot); +export type ListScreenshotsResponse = z.infer< + typeof ListScreenshotsResponseSchema +>; + +const GetScreenshotInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type GetScreenshotInput = z.infer; +const GetScreenshotResponseSchema = BannerbearScreenshot; +export type GetScreenshotResponse = z.infer; + +// ─── Signed URLs ───────────────────────────────────────────── +const GetSignedBasesInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type GetSignedBasesInput = z.infer; +const GetSignedBasesResponseSchema = z.array(BannerbearSignedBase); +export type GetSignedBasesResponse = z.infer< + typeof GetSignedBasesResponseSchema +>; + +const CreateSignedBaseInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type CreateSignedBaseInput = z.infer; +const CreateSignedBaseResponseSchema = BannerbearSignedBase; +export type CreateSignedBaseResponse = z.infer< + typeof CreateSignedBaseResponseSchema +>; + +// ─── Webhooks API ──────────────────────────────────────────── +const GetWebhookInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type GetWebhookInput = z.infer; +const GetWebhookResponseSchema = BannerbearWebhookObj; +export type GetWebhookResponse = z.infer; + +const CreateWebhookInputSchema = z.object({ + url: z.string(), + event: z.string().optional(), + project_id: z.string().optional(), +}); +export type CreateWebhookInput = z.infer; +const CreateWebhookResponseSchema = BannerbearWebhookObj; +export type CreateWebhookResponse = z.infer; + +const DeleteWebhookInputSchema = z.object({ + uid: z.string(), + project_id: z.string().optional(), +}); +export type DeleteWebhookInput = z.infer; +const DeleteWebhookResponseSchema = z.object({ success: z.boolean() }); +export type DeleteWebhookResponse = z.infer; + +// ─── Misc ──────────────────────────────────────────────────── +const GetFontsInputSchema = z.object({}); +export type GetFontsInput = z.infer; +const GetFontsResponseSchema = z.array(BannerbearFont); +export type GetFontsResponse = z.infer; + +const ListEffectsInputSchema = z.object({}); +export type ListEffectsInput = z.infer; +const ListEffectsResponseSchema = z.array(BannerbearEffect); +export type ListEffectsResponse = z.infer; + +const JoinPdfsInputSchema = z.object({ + pdf_urls: z.array(z.string()), + project_id: z.string().optional(), + webhook_url: z.string().optional(), + metadata: z.string().optional(), +}); +export type JoinPdfsInput = z.infer; +const JoinPdfsResponseSchema = BannerbearPdfJoin; +export type JoinPdfsResponse = z.infer; + +// ─── Workflows ─────────────────────────────────────────────── +const ListWorkflowsInputSchema = PaginationInput.pick({ page: true }); +export type ListWorkflowsInput = z.infer; +const ListWorkflowsResponseSchema = z.array(BannerbearWorkflow); +export type ListWorkflowsResponse = z.infer; + +const GetWorkflowInputSchema = z.object({ uid: z.string() }); +export type GetWorkflowInput = z.infer; +const GetWorkflowResponseSchema = BannerbearWorkflow; +export type GetWorkflowResponse = z.infer; + +const CreateWorkflowRunInputSchema = z.object({ + workflow: z.string(), + inputs: z.record(z.string(), z.unknown()).optional(), + webhook_url: z.string().optional(), +}); +export type CreateWorkflowRunInput = z.infer< + typeof CreateWorkflowRunInputSchema +>; +const CreateWorkflowRunResponseSchema = BannerbearWorkflowRun; +export type CreateWorkflowRunResponse = z.infer< + typeof CreateWorkflowRunResponseSchema +>; + +const GetWorkflowRunInputSchema = z.object({ uid: z.string() }); +export type GetWorkflowRunInput = z.infer; +const GetWorkflowRunResponseSchema = BannerbearWorkflowRun; +export type GetWorkflowRunResponse = z.infer< + typeof GetWorkflowRunResponseSchema +>; + +const ListWorkflowRunsInputSchema = PaginationInput.pick({ page: true }); +export type ListWorkflowRunsInput = z.infer; +const ListWorkflowRunsResponseSchema = z.array(BannerbearWorkflowRun); +export type ListWorkflowRunsResponse = z.infer< + typeof ListWorkflowRunsResponseSchema +>; + +// ─── Aggregate types ───────────────────────────────────────── +export type BannerbearEndpointInputs = { + getAccountInfo: GetAccountInfoInput; + getAuth: GetAuthInput; + listProjects: ListProjectsInput; + getProject: GetProjectInput; + createProject: CreateProjectInput; + hydrateProject: HydrateProjectInput; + listTemplates: ListTemplatesInput; + getTemplate: GetTemplateInput; + createTemplate: CreateTemplateInput; + deleteTemplate: DeleteTemplateInput; + importTemplate: ImportTemplateInput; + listTemplateSets: ListTemplateSetsInput; + getTemplateSet: GetTemplateSetInput; + createTemplateSet: CreateTemplateSetInput; + updateTemplateSet: UpdateTemplateSetInput; + listImages: ListImagesInput; + getImage: GetImageInput; + listVideos: ListVideosInput; + listVideoTemplates: ListVideoTemplatesInput; + createVideoTemplate: CreateVideoTemplateInput; + listAnimatedGifs: ListAnimatedGifsInput; + getAnimatedGif: GetAnimatedGifInput; + listCollections: ListCollectionsInput; + listScreenshots: ListScreenshotsInput; + getScreenshot: GetScreenshotInput; + getSignedBases: GetSignedBasesInput; + createSignedBase: CreateSignedBaseInput; + getWebhook: GetWebhookInput; + createWebhook: CreateWebhookInput; + deleteWebhook: DeleteWebhookInput; + getFonts: GetFontsInput; + listEffects: ListEffectsInput; + joinPdfs: JoinPdfsInput; + listWorkflows: ListWorkflowsInput; + getWorkflow: GetWorkflowInput; + createWorkflowRun: CreateWorkflowRunInput; + getWorkflowRun: GetWorkflowRunInput; + listWorkflowRuns: ListWorkflowRunsInput; +}; + +export type BannerbearEndpointOutputs = { + getAccountInfo: GetAccountInfoResponse; + getAuth: GetAuthResponse; + listProjects: ListProjectsResponse; + getProject: GetProjectResponse; + createProject: CreateProjectResponse; + hydrateProject: HydrateProjectResponse; + listTemplates: ListTemplatesResponse; + getTemplate: GetTemplateResponse; + createTemplate: CreateTemplateResponse; + deleteTemplate: DeleteTemplateResponse; + importTemplate: ImportTemplateResponse; + listTemplateSets: ListTemplateSetsResponse; + getTemplateSet: GetTemplateSetResponse; + createTemplateSet: CreateTemplateSetResponse; + updateTemplateSet: UpdateTemplateSetResponse; + listImages: ListImagesResponse; + getImage: GetImageResponse; + listVideos: ListVideosResponse; + listVideoTemplates: ListVideoTemplatesResponse; + createVideoTemplate: CreateVideoTemplateResponse; + listAnimatedGifs: ListAnimatedGifsResponse; + getAnimatedGif: GetAnimatedGifResponse; + listCollections: ListCollectionsResponse; + listScreenshots: ListScreenshotsResponse; + getScreenshot: GetScreenshotResponse; + getSignedBases: GetSignedBasesResponse; + createSignedBase: CreateSignedBaseResponse; + getWebhook: GetWebhookResponse; + createWebhook: CreateWebhookResponse; + deleteWebhook: DeleteWebhookResponse; + getFonts: GetFontsResponse; + listEffects: ListEffectsResponse; + joinPdfs: JoinPdfsResponse; + listWorkflows: ListWorkflowsResponse; + getWorkflow: GetWorkflowResponse; + createWorkflowRun: CreateWorkflowRunResponse; + getWorkflowRun: GetWorkflowRunResponse; + listWorkflowRuns: ListWorkflowRunsResponse; +}; + +export const BannerbearEndpointInputSchemas = { + getAccountInfo: GetAccountInfoInputSchema, + getAuth: GetAuthInputSchema, + listProjects: ListProjectsInputSchema, + getProject: GetProjectInputSchema, + createProject: CreateProjectInputSchema, + hydrateProject: HydrateProjectInputSchema, + listTemplates: ListTemplatesInputSchema, + getTemplate: GetTemplateInputSchema, + createTemplate: CreateTemplateInputSchema, + deleteTemplate: DeleteTemplateInputSchema, + importTemplate: ImportTemplateInputSchema, + listTemplateSets: ListTemplateSetsInputSchema, + getTemplateSet: GetTemplateSetInputSchema, + createTemplateSet: CreateTemplateSetInputSchema, + updateTemplateSet: UpdateTemplateSetInputSchema, + listImages: ListImagesInputSchema, + getImage: GetImageInputSchema, + listVideos: ListVideosInputSchema, + listVideoTemplates: ListVideoTemplatesInputSchema, + createVideoTemplate: CreateVideoTemplateInputSchema, + listAnimatedGifs: ListAnimatedGifsInputSchema, + getAnimatedGif: GetAnimatedGifInputSchema, + listCollections: ListCollectionsInputSchema, + listScreenshots: ListScreenshotsInputSchema, + getScreenshot: GetScreenshotInputSchema, + getSignedBases: GetSignedBasesInputSchema, + createSignedBase: CreateSignedBaseInputSchema, + getWebhook: GetWebhookInputSchema, + createWebhook: CreateWebhookInputSchema, + deleteWebhook: DeleteWebhookInputSchema, + getFonts: GetFontsInputSchema, + listEffects: ListEffectsInputSchema, + joinPdfs: JoinPdfsInputSchema, + listWorkflows: ListWorkflowsInputSchema, + getWorkflow: GetWorkflowInputSchema, + createWorkflowRun: CreateWorkflowRunInputSchema, + getWorkflowRun: GetWorkflowRunInputSchema, + listWorkflowRuns: ListWorkflowRunsInputSchema, +} as const; + +export const BannerbearEndpointOutputSchemas = { + getAccountInfo: GetAccountInfoResponseSchema, + getAuth: GetAuthResponseSchema, + listProjects: ListProjectsResponseSchema, + getProject: GetProjectResponseSchema, + createProject: CreateProjectResponseSchema, + hydrateProject: HydrateProjectResponseSchema, + listTemplates: ListTemplatesResponseSchema, + getTemplate: GetTemplateResponseSchema, + createTemplate: CreateTemplateResponseSchema, + deleteTemplate: DeleteTemplateResponseSchema, + importTemplate: ImportTemplateResponseSchema, + listTemplateSets: ListTemplateSetsResponseSchema, + getTemplateSet: GetTemplateSetResponseSchema, + createTemplateSet: CreateTemplateSetResponseSchema, + updateTemplateSet: UpdateTemplateSetResponseSchema, + listImages: ListImagesResponseSchema, + getImage: GetImageResponseSchema, + listVideos: ListVideosResponseSchema, + listVideoTemplates: ListVideoTemplatesResponseSchema, + createVideoTemplate: CreateVideoTemplateResponseSchema, + listAnimatedGifs: ListAnimatedGifsResponseSchema, + getAnimatedGif: GetAnimatedGifResponseSchema, + listCollections: ListCollectionsResponseSchema, + listScreenshots: ListScreenshotsResponseSchema, + getScreenshot: GetScreenshotResponseSchema, + getSignedBases: GetSignedBasesResponseSchema, + createSignedBase: CreateSignedBaseResponseSchema, + getWebhook: GetWebhookResponseSchema, + createWebhook: CreateWebhookResponseSchema, + deleteWebhook: DeleteWebhookResponseSchema, + getFonts: GetFontsResponseSchema, + listEffects: ListEffectsResponseSchema, + joinPdfs: JoinPdfsResponseSchema, + listWorkflows: ListWorkflowsResponseSchema, + getWorkflow: GetWorkflowResponseSchema, + createWorkflowRun: CreateWorkflowRunResponseSchema, + getWorkflowRun: GetWorkflowRunResponseSchema, + listWorkflowRuns: ListWorkflowRunsResponseSchema, +} as const; diff --git a/packages/bannerbear/endpoints/videos.ts b/packages/bannerbear/endpoints/videos.ts new file mode 100644 index 000000000..05394b8f3 --- /dev/null +++ b/packages/bannerbear/endpoints/videos.ts @@ -0,0 +1,66 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const listVideos: BannerbearEndpoints['listVideos'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listVideos'] + >('/v5/videos', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.videos.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const listVideoTemplates: BannerbearEndpoints['listVideoTemplates'] = + async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listVideoTemplates'] + >('/v5/video_templates', ctx.key, { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + project_id: input.project_id, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.video_templates.list', + { ...input }, + 'completed', + ); + return response; + }; + +export const createVideoTemplate: BannerbearEndpoints['createVideoTemplate'] = + async (ctx, input) => { + const { project_id, ...body } = input; + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createVideoTemplate'] + >('/v5/video_templates', ctx.key, { + method: 'POST', + body: { ...body, project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.video_templates.create', + { ...input }, + 'completed', + ); + return response; + }; diff --git a/packages/bannerbear/endpoints/webhooks-api.ts b/packages/bannerbear/endpoints/webhooks-api.ts new file mode 100644 index 000000000..28c2648a4 --- /dev/null +++ b/packages/bannerbear/endpoints/webhooks-api.ts @@ -0,0 +1,56 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const get: BannerbearEndpoints['getWebhook'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getWebhook'] + >(`/v5/webhooks/${input.uid}`, ctx.key, { + method: 'GET', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.webhooks.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createWebhook'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createWebhook'] + >('/v5/webhooks', ctx.key, { + method: 'POST', + body: { url: input.url, event: input.event, project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.webhooks.create', + { ...input }, + 'completed', + ); + return response; +}; + +export const deleteWebhook: BannerbearEndpoints['deleteWebhook'] = async ( + ctx, + input, +) => { + await makeBannerbearRequest(`/v5/webhooks/${input.uid}`, ctx.key, { + method: 'DELETE', + query: { project_id: input.project_id }, + }); + await logEventFromContext( + ctx, + 'bannerbear.webhooks.delete', + { ...input }, + 'completed', + ); + return { success: true }; +}; diff --git a/packages/bannerbear/endpoints/workflows.ts b/packages/bannerbear/endpoints/workflows.ts new file mode 100644 index 000000000..405e3f1ad --- /dev/null +++ b/packages/bannerbear/endpoints/workflows.ts @@ -0,0 +1,92 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const listWorkflows: BannerbearEndpoints['listWorkflows'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listWorkflows'] + >('/v5/workflows', ctx.key, { method: 'GET', query: { page: input.page } }); + await logEventFromContext( + ctx, + 'bannerbear.workflows.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const getWorkflow: BannerbearEndpoints['getWorkflow'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getWorkflow'] + >(`/v5/workflows/${input.uid}`, ctx.key, { method: 'GET' }); + await logEventFromContext( + ctx, + 'bannerbear.workflows.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const createWorkflowRun: BannerbearEndpoints['createWorkflowRun'] = + async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createWorkflowRun'] + >('/v5/workflow_runs', ctx.key, { + method: 'POST', + body: { + workflow: input.workflow, + inputs: input.inputs, + webhook_url: input.webhook_url, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.workflow_runs.create', + { ...input }, + 'completed', + ); + return response; + }; + +export const getWorkflowRun: BannerbearEndpoints['getWorkflowRun'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getWorkflowRun'] + >(`/v5/workflow_runs/${input.uid}`, ctx.key, { method: 'GET' }); + await logEventFromContext( + ctx, + 'bannerbear.workflow_runs.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const listWorkflowRuns: BannerbearEndpoints['listWorkflowRuns'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listWorkflowRuns'] + >('/v5/workflow_runs', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.workflow_runs.list', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/error-handlers.ts b/packages/bannerbear/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/bannerbear/error-handlers.ts @@ -0,0 +1,31 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/bannerbear/index.ts b/packages/bannerbear/index.ts new file mode 100644 index 000000000..7047bebf4 --- /dev/null +++ b/packages/bannerbear/index.ts @@ -0,0 +1,736 @@ +import type { + AuthTypes, + BindEndpoints, + BindWebhooks, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + CorsairWebhook, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, + RequiredPluginWebhookSchemas, +} from 'corsair/core'; +import { + Account, + Animations, + Collections, + Images, + Misc, + Projects, + Screenshots, + SignedUrls, + TemplateSets, + Templates, + Videos, + WebhooksApi, + Workflows, +} from './endpoints'; +import type { + BannerbearEndpointInputs, + BannerbearEndpointOutputs, +} from './endpoints/types'; +import { + BannerbearEndpointInputSchemas, + BannerbearEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { BannerbearSchema } from './schema'; +import { ImageWebhooks, VideoWebhooks } from './webhooks'; +import { matchBannerbearTenantWebhook } from './webhooks/tenant-matcher'; +import type { + BannerbearWebhookOutputs, + ImageCompletedEvent, + VideoCompletedEvent, +} from './webhooks/types'; +import { + ImageCompletedEventSchema, + VideoCompletedEventSchema, +} from './webhooks/types'; + +export type BannerbearPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + webhookSecret?: string; + hooks?: InternalBannerbearPlugin['hooks']; + webhookHooks?: InternalBannerbearPlugin['webhookHooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type BannerbearContext = CorsairPluginContext< + typeof BannerbearSchema, + BannerbearPluginOptions +>; + +export type BannerbearKeyBuilderContext = + KeyBuilderContext; + +export type BannerbearBoundEndpoints = BindEndpoints< + typeof bannerbearEndpointsNested +>; + +type BannerbearEndpoint = + CorsairEndpoint< + BannerbearContext, + BannerbearEndpointInputs[K], + BannerbearEndpointOutputs[K] + >; + +export type BannerbearEndpoints = { + // Account + getAccountInfo: BannerbearEndpoint<'getAccountInfo'>; + getAuth: BannerbearEndpoint<'getAuth'>; + // Projects + listProjects: BannerbearEndpoint<'listProjects'>; + getProject: BannerbearEndpoint<'getProject'>; + createProject: BannerbearEndpoint<'createProject'>; + hydrateProject: BannerbearEndpoint<'hydrateProject'>; + // Templates + listTemplates: BannerbearEndpoint<'listTemplates'>; + getTemplate: BannerbearEndpoint<'getTemplate'>; + createTemplate: BannerbearEndpoint<'createTemplate'>; + deleteTemplate: BannerbearEndpoint<'deleteTemplate'>; + importTemplate: BannerbearEndpoint<'importTemplate'>; + // Template Sets + listTemplateSets: BannerbearEndpoint<'listTemplateSets'>; + getTemplateSet: BannerbearEndpoint<'getTemplateSet'>; + createTemplateSet: BannerbearEndpoint<'createTemplateSet'>; + updateTemplateSet: BannerbearEndpoint<'updateTemplateSet'>; + // Images + listImages: BannerbearEndpoint<'listImages'>; + getImage: BannerbearEndpoint<'getImage'>; + // Videos + listVideos: BannerbearEndpoint<'listVideos'>; + listVideoTemplates: BannerbearEndpoint<'listVideoTemplates'>; + createVideoTemplate: BannerbearEndpoint<'createVideoTemplate'>; + // Animated GIFs + listAnimatedGifs: BannerbearEndpoint<'listAnimatedGifs'>; + getAnimatedGif: BannerbearEndpoint<'getAnimatedGif'>; + // Collections + listCollections: BannerbearEndpoint<'listCollections'>; + // Screenshots + listScreenshots: BannerbearEndpoint<'listScreenshots'>; + getScreenshot: BannerbearEndpoint<'getScreenshot'>; + // Signed URLs + getSignedBases: BannerbearEndpoint<'getSignedBases'>; + createSignedBase: BannerbearEndpoint<'createSignedBase'>; + // Webhooks API + getWebhook: BannerbearEndpoint<'getWebhook'>; + createWebhook: BannerbearEndpoint<'createWebhook'>; + deleteWebhook: BannerbearEndpoint<'deleteWebhook'>; + // Misc + getFonts: BannerbearEndpoint<'getFonts'>; + listEffects: BannerbearEndpoint<'listEffects'>; + joinPdfs: BannerbearEndpoint<'joinPdfs'>; + // Workflows + listWorkflows: BannerbearEndpoint<'listWorkflows'>; + getWorkflow: BannerbearEndpoint<'getWorkflow'>; + createWorkflowRun: BannerbearEndpoint<'createWorkflowRun'>; + getWorkflowRun: BannerbearEndpoint<'getWorkflowRun'>; + listWorkflowRuns: BannerbearEndpoint<'listWorkflowRuns'>; +}; + +type BannerbearWebhook< + K extends keyof BannerbearWebhookOutputs, + TEvent, +> = CorsairWebhook; + +export type BannerbearWebhooks = { + imageCompleted: BannerbearWebhook<'imageCompleted', ImageCompletedEvent>; + videoCompleted: BannerbearWebhook<'videoCompleted', VideoCompletedEvent>; +}; + +export type BannerbearBoundWebhooks = BindWebhooks; + +const bannerbearEndpointsNested = { + account: { + getAccountInfo: Account.getAccountInfo, + getAuth: Account.getAuth, + }, + projects: { + list: Projects.list, + get: Projects.get, + create: Projects.create, + hydrate: Projects.hydrate, + }, + templates: { + list: Templates.list, + get: Templates.get, + create: Templates.create, + delete: Templates.deleteTemplate, + import: Templates.importTemplate, + }, + templateSets: { + list: TemplateSets.list, + get: TemplateSets.get, + create: TemplateSets.create, + update: TemplateSets.update, + }, + images: { + list: Images.list, + get: Images.get, + }, + videos: { + listVideos: Videos.listVideos, + listVideoTemplates: Videos.listVideoTemplates, + createVideoTemplate: Videos.createVideoTemplate, + }, + animations: { + list: Animations.list, + get: Animations.get, + }, + collections: { + list: Collections.list, + }, + screenshots: { + list: Screenshots.list, + get: Screenshots.get, + }, + signedUrls: { + getSignedBases: SignedUrls.getSignedBases, + createSignedBase: SignedUrls.createSignedBase, + }, + webhooksApi: { + get: WebhooksApi.get, + create: WebhooksApi.create, + delete: WebhooksApi.deleteWebhook, + }, + misc: { + getFonts: Misc.getFonts, + listEffects: Misc.listEffects, + joinPdfs: Misc.joinPdfs, + }, + workflows: { + listWorkflows: Workflows.listWorkflows, + getWorkflow: Workflows.getWorkflow, + createWorkflowRun: Workflows.createWorkflowRun, + getWorkflowRun: Workflows.getWorkflowRun, + listWorkflowRuns: Workflows.listWorkflowRuns, + }, +} as const; + +const bannerbearWebhooksNested = { + image: { + imageCompleted: ImageWebhooks.imageCompleted, + }, + video: { + videoCompleted: VideoWebhooks.videoCompleted, + }, +} as const; + +export const bannerbearEndpointSchemas = { + 'account.getAccountInfo': { + input: BannerbearEndpointInputSchemas.getAccountInfo, + output: BannerbearEndpointOutputSchemas.getAccountInfo, + }, + 'account.getAuth': { + input: BannerbearEndpointInputSchemas.getAuth, + output: BannerbearEndpointOutputSchemas.getAuth, + }, + 'projects.list': { + input: BannerbearEndpointInputSchemas.listProjects, + output: BannerbearEndpointOutputSchemas.listProjects, + }, + 'projects.get': { + input: BannerbearEndpointInputSchemas.getProject, + output: BannerbearEndpointOutputSchemas.getProject, + }, + 'projects.create': { + input: BannerbearEndpointInputSchemas.createProject, + output: BannerbearEndpointOutputSchemas.createProject, + }, + 'projects.hydrate': { + input: BannerbearEndpointInputSchemas.hydrateProject, + output: BannerbearEndpointOutputSchemas.hydrateProject, + }, + 'templates.list': { + input: BannerbearEndpointInputSchemas.listTemplates, + output: BannerbearEndpointOutputSchemas.listTemplates, + }, + 'templates.get': { + input: BannerbearEndpointInputSchemas.getTemplate, + output: BannerbearEndpointOutputSchemas.getTemplate, + }, + 'templates.create': { + input: BannerbearEndpointInputSchemas.createTemplate, + output: BannerbearEndpointOutputSchemas.createTemplate, + }, + 'templates.delete': { + input: BannerbearEndpointInputSchemas.deleteTemplate, + output: BannerbearEndpointOutputSchemas.deleteTemplate, + }, + 'templates.import': { + input: BannerbearEndpointInputSchemas.importTemplate, + output: BannerbearEndpointOutputSchemas.importTemplate, + }, + 'templateSets.list': { + input: BannerbearEndpointInputSchemas.listTemplateSets, + output: BannerbearEndpointOutputSchemas.listTemplateSets, + }, + 'templateSets.get': { + input: BannerbearEndpointInputSchemas.getTemplateSet, + output: BannerbearEndpointOutputSchemas.getTemplateSet, + }, + 'templateSets.create': { + input: BannerbearEndpointInputSchemas.createTemplateSet, + output: BannerbearEndpointOutputSchemas.createTemplateSet, + }, + 'templateSets.update': { + input: BannerbearEndpointInputSchemas.updateTemplateSet, + output: BannerbearEndpointOutputSchemas.updateTemplateSet, + }, + 'images.list': { + input: BannerbearEndpointInputSchemas.listImages, + output: BannerbearEndpointOutputSchemas.listImages, + }, + 'images.get': { + input: BannerbearEndpointInputSchemas.getImage, + output: BannerbearEndpointOutputSchemas.getImage, + }, + 'videos.listVideos': { + input: BannerbearEndpointInputSchemas.listVideos, + output: BannerbearEndpointOutputSchemas.listVideos, + }, + 'videos.listVideoTemplates': { + input: BannerbearEndpointInputSchemas.listVideoTemplates, + output: BannerbearEndpointOutputSchemas.listVideoTemplates, + }, + 'videos.createVideoTemplate': { + input: BannerbearEndpointInputSchemas.createVideoTemplate, + output: BannerbearEndpointOutputSchemas.createVideoTemplate, + }, + 'animations.list': { + input: BannerbearEndpointInputSchemas.listAnimatedGifs, + output: BannerbearEndpointOutputSchemas.listAnimatedGifs, + }, + 'animations.get': { + input: BannerbearEndpointInputSchemas.getAnimatedGif, + output: BannerbearEndpointOutputSchemas.getAnimatedGif, + }, + 'collections.list': { + input: BannerbearEndpointInputSchemas.listCollections, + output: BannerbearEndpointOutputSchemas.listCollections, + }, + 'screenshots.list': { + input: BannerbearEndpointInputSchemas.listScreenshots, + output: BannerbearEndpointOutputSchemas.listScreenshots, + }, + 'screenshots.get': { + input: BannerbearEndpointInputSchemas.getScreenshot, + output: BannerbearEndpointOutputSchemas.getScreenshot, + }, + 'signedUrls.getSignedBases': { + input: BannerbearEndpointInputSchemas.getSignedBases, + output: BannerbearEndpointOutputSchemas.getSignedBases, + }, + 'signedUrls.createSignedBase': { + input: BannerbearEndpointInputSchemas.createSignedBase, + output: BannerbearEndpointOutputSchemas.createSignedBase, + }, + 'webhooksApi.get': { + input: BannerbearEndpointInputSchemas.getWebhook, + output: BannerbearEndpointOutputSchemas.getWebhook, + }, + 'webhooksApi.create': { + input: BannerbearEndpointInputSchemas.createWebhook, + output: BannerbearEndpointOutputSchemas.createWebhook, + }, + 'webhooksApi.delete': { + input: BannerbearEndpointInputSchemas.deleteWebhook, + output: BannerbearEndpointOutputSchemas.deleteWebhook, + }, + 'misc.getFonts': { + input: BannerbearEndpointInputSchemas.getFonts, + output: BannerbearEndpointOutputSchemas.getFonts, + }, + 'misc.listEffects': { + input: BannerbearEndpointInputSchemas.listEffects, + output: BannerbearEndpointOutputSchemas.listEffects, + }, + 'misc.joinPdfs': { + input: BannerbearEndpointInputSchemas.joinPdfs, + output: BannerbearEndpointOutputSchemas.joinPdfs, + }, + 'workflows.listWorkflows': { + input: BannerbearEndpointInputSchemas.listWorkflows, + output: BannerbearEndpointOutputSchemas.listWorkflows, + }, + 'workflows.getWorkflow': { + input: BannerbearEndpointInputSchemas.getWorkflow, + output: BannerbearEndpointOutputSchemas.getWorkflow, + }, + 'workflows.createWorkflowRun': { + input: BannerbearEndpointInputSchemas.createWorkflowRun, + output: BannerbearEndpointOutputSchemas.createWorkflowRun, + }, + 'workflows.getWorkflowRun': { + input: BannerbearEndpointInputSchemas.getWorkflowRun, + output: BannerbearEndpointOutputSchemas.getWorkflowRun, + }, + 'workflows.listWorkflowRuns': { + input: BannerbearEndpointInputSchemas.listWorkflowRuns, + output: BannerbearEndpointOutputSchemas.listWorkflowRuns, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof bannerbearEndpointsNested +>; + +const bannerbearWebhookSchemas = { + 'image.imageCompleted': { + description: 'Fires when a Bannerbear image finishes rendering', + payload: ImageCompletedEventSchema, + response: ImageCompletedEventSchema, + }, + 'video.videoCompleted': { + description: 'Fires when a Bannerbear video finishes rendering', + payload: VideoCompletedEventSchema, + response: VideoCompletedEventSchema, + }, +} as const satisfies RequiredPluginWebhookSchemas< + typeof bannerbearWebhooksNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const bannerbearEndpointMeta = { + 'account.getAccountInfo': { + riskLevel: 'read', + description: 'Get account info including plan and quota', + }, + 'account.getAuth': { + riskLevel: 'read', + description: 'Verify API authentication and check project context', + }, + 'projects.list': { + riskLevel: 'read', + description: 'List all projects in the account', + }, + 'projects.get': { + riskLevel: 'read', + description: 'Get a project by UID', + }, + 'projects.create': { + riskLevel: 'write', + description: 'Create a new project', + }, + 'projects.hydrate': { + riskLevel: 'write', + description: 'Hydrate a project by copying templates from another project', + }, + 'templates.list': { + riskLevel: 'read', + description: 'List all templates in a project', + }, + 'templates.get': { + riskLevel: 'read', + description: 'Get a template by UID with layer defaults', + }, + 'templates.create': { + riskLevel: 'write', + description: 'Create a new blank template', + }, + 'templates.delete': { + riskLevel: 'write', + description: 'Delete a template by UID', + }, + 'templates.import': { + riskLevel: 'write', + description: 'Import a template from the Bannerbear template library', + }, + 'templateSets.list': { + riskLevel: 'read', + description: 'List all template sets in a project', + }, + 'templateSets.get': { + riskLevel: 'read', + description: 'Get a template set by UID', + }, + 'templateSets.create': { + riskLevel: 'write', + description: 'Create a new template set', + }, + 'templateSets.update': { + riskLevel: 'write', + description: 'Update a template set', + }, + 'images.list': { + riskLevel: 'read', + description: 'List all images in a project', + }, + 'images.get': { + riskLevel: 'read', + description: 'Get an image by UID', + }, + 'videos.listVideos': { + riskLevel: 'read', + description: 'List all videos in a project', + }, + 'videos.listVideoTemplates': { + riskLevel: 'read', + description: 'List all video templates in a project', + }, + 'videos.createVideoTemplate': { + riskLevel: 'write', + description: 'Create a new video template', + }, + 'animations.list': { + riskLevel: 'read', + description: 'List all animated GIFs in a project', + }, + 'animations.get': { + riskLevel: 'read', + description: 'Get an animated GIF by UID', + }, + 'collections.list': { + riskLevel: 'read', + description: 'List all collections in a project', + }, + 'screenshots.list': { + riskLevel: 'read', + description: 'List all screenshots in a project', + }, + 'screenshots.get': { + riskLevel: 'read', + description: 'Get a screenshot by UID', + }, + 'signedUrls.getSignedBases': { + riskLevel: 'read', + description: 'Get signed URL bases for a template', + }, + 'signedUrls.createSignedBase': { + riskLevel: 'write', + description: 'Create a signed URL base for a template', + }, + 'webhooksApi.get': { + riskLevel: 'read', + description: 'Get a webhook by UID', + }, + 'webhooksApi.create': { + riskLevel: 'write', + description: 'Create a project-level webhook', + }, + 'webhooksApi.delete': { + riskLevel: 'write', + description: 'Delete a webhook by UID', + }, + 'misc.getFonts': { + riskLevel: 'read', + description: 'Get all available fonts', + }, + 'misc.listEffects': { + riskLevel: 'read', + description: 'List all available image effects', + }, + 'misc.joinPdfs': { + riskLevel: 'write', + description: 'Merge multiple PDF files into one', + }, + 'workflows.listWorkflows': { + riskLevel: 'read', + description: 'List all workflows in the workspace', + }, + 'workflows.getWorkflow': { + riskLevel: 'read', + description: 'Get a workflow by UID', + }, + 'workflows.createWorkflowRun': { + riskLevel: 'write', + description: 'Run a workflow with inputs', + }, + 'workflows.getWorkflowRun': { + riskLevel: 'read', + description: 'Get a workflow run by UID', + }, + 'workflows.listWorkflowRuns': { + riskLevel: 'read', + description: 'List all workflow runs', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof bannerbearEndpointsNested +>; + +export const bannerbearAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseBannerbearPlugin = + CorsairPlugin< + 'bannerbear', + typeof BannerbearSchema, + typeof bannerbearEndpointsNested, + typeof bannerbearWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalBannerbearPlugin = + BaseBannerbearPlugin; + +export type ExternalBannerbearPlugin = + BaseBannerbearPlugin; + +export function bannerbear( + incomingOptions: BannerbearPluginOptions & T = {} as BannerbearPluginOptions & + T, +): ExternalBannerbearPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'bannerbear', + authConfig: bannerbearAuthConfig, + schema: BannerbearSchema, + options: options, + hooks: options.hooks, + webhookHooks: options.webhookHooks, + endpoints: bannerbearEndpointsNested, + webhooks: bannerbearWebhooksNested, + endpointMeta: bannerbearEndpointMeta, + endpointSchemas: bannerbearEndpointSchemas, + webhookSchemas: bannerbearWebhookSchemas, + pluginWebhookMatcher: (request) => { + const headers = request.headers; + // Bannerbear webhooks are identified by having a uid field in the body + // and coming from Bannerbear's servers. There's no signature header. + // We check for the presence of the body having uid + status fields. + if ('x-bannerbear-signature' in headers) return true; + // Fallback: check body structure for Bannerbear-like payloads + const body = + typeof request.body === 'string' + ? (() => { + try { + return JSON.parse(request.body); + } catch { + return null; + } + })() + : request.body; + return ( + body !== null && + typeof body === 'object' && + 'uid' in body && + 'status' in body + ); + }, + pluginTenantWebhookMatcher: matchBannerbearTenantWebhook, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: BannerbearKeyBuilderContext, source) => { + if (source === 'webhook' && options.webhookSecret) { + return options.webhookSecret; + } + + if (source === 'webhook') { + const res = await ctx.keys.get_webhook_signature(); + return res ?? ''; + } + + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + return res ?? ''; + } + + return ''; + }, + } satisfies InternalBannerbearPlugin; +} + +export type { + BannerbearEndpointInputs, + BannerbearEndpointOutputs, + CreateProjectInput, + CreateProjectResponse, + CreateSignedBaseInput, + CreateSignedBaseResponse, + CreateTemplateInput, + CreateTemplateResponse, + CreateTemplateSetInput, + CreateTemplateSetResponse, + CreateVideoTemplateInput, + CreateVideoTemplateResponse, + CreateWebhookInput, + CreateWebhookResponse, + CreateWorkflowRunInput, + CreateWorkflowRunResponse, + DeleteTemplateInput, + DeleteTemplateResponse, + DeleteWebhookInput, + DeleteWebhookResponse, + GetAccountInfoInput, + GetAccountInfoResponse, + GetAnimatedGifInput, + GetAnimatedGifResponse, + GetAuthInput, + GetAuthResponse, + GetFontsInput, + GetFontsResponse, + GetImageInput, + GetImageResponse, + GetProjectInput, + GetProjectResponse, + GetScreenshotInput, + GetScreenshotResponse, + GetSignedBasesInput, + GetSignedBasesResponse, + GetTemplateInput, + GetTemplateResponse, + GetTemplateSetInput, + GetTemplateSetResponse, + GetWebhookInput, + GetWebhookResponse, + GetWorkflowInput, + GetWorkflowResponse, + GetWorkflowRunInput, + GetWorkflowRunResponse, + HydrateProjectInput, + HydrateProjectResponse, + ImportTemplateInput, + ImportTemplateResponse, + JoinPdfsInput, + JoinPdfsResponse, + ListAnimatedGifsInput, + ListAnimatedGifsResponse, + ListCollectionsInput, + ListCollectionsResponse, + ListEffectsInput, + ListEffectsResponse, + ListImagesInput, + ListImagesResponse, + ListProjectsInput, + ListProjectsResponse, + ListScreenshotsInput, + ListScreenshotsResponse, + ListTemplateSetsInput, + ListTemplateSetsResponse, + ListTemplatesInput, + ListTemplatesResponse, + ListVideosInput, + ListVideosResponse, + ListVideoTemplatesInput, + ListVideoTemplatesResponse, + ListWorkflowRunsInput, + ListWorkflowRunsResponse, + ListWorkflowsInput, + ListWorkflowsResponse, + UpdateTemplateSetInput, + UpdateTemplateSetResponse, +} from './endpoints/types'; +export type { + BannerbearWebhookOutputs, + ImageCompletedEvent, + VideoCompletedEvent, +} from './webhooks/types'; diff --git a/packages/bannerbear/jest.config.cjs b/packages/bannerbear/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/bannerbear/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/bannerbear/package.json b/packages/bannerbear/package.json new file mode 100644 index 000000000..7a05df07d --- /dev/null +++ b/packages/bannerbear/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/bannerbear", + "version": "0.1.0", + "description": "Bannerbear plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "bannerbear", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/bannerbear/schema.test.ts b/packages/bannerbear/schema.test.ts new file mode 100644 index 000000000..a1c9989bf --- /dev/null +++ b/packages/bannerbear/schema.test.ts @@ -0,0 +1,20 @@ +import { BannerbearSchema } from './schema'; + +describe('Bannerbear schema', () => { + it('declares a semver version', () => { + expect(BannerbearSchema.version).toBeDefined(); + expect(BannerbearSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof BannerbearSchema.entities).toBe('object'); + expect(BannerbearSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(BannerbearSchema.entities))).toBe(true); + for (const entity of Object.values(BannerbearSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/bannerbear/schema/database.ts b/packages/bannerbear/schema/database.ts new file mode 100644 index 000000000..30bf4478b --- /dev/null +++ b/packages/bannerbear/schema/database.ts @@ -0,0 +1,216 @@ +import { z } from 'zod'; + +export const BannerbearAccount = z.object({ + created_at: z.string().nullable().optional(), + uid: z.string(), + plan: z.string().nullable().optional(), + quota: z + .object({ + max: z.number(), + current: z.number(), + remaining: z.number(), + }) + .nullable() + .optional(), + workspace: z.string().nullable().optional(), + api_key: z + .object({ + name: z.string().nullable().optional(), + scopes: z.array(z.string()).optional(), + allowed_origins: z.array(z.string()).optional(), + }) + .nullable() + .optional(), +}); +export type BannerbearAccount = z.infer; + +export const BannerbearProject = z.object({ + uid: z.string(), + name: z.string(), + created_at: z.string().nullable().optional(), + templates_count: z.number().nullable().optional(), + api_key: z.string().nullable().optional(), +}); +export type BannerbearProject = z.infer; + +export const BannerbearTemplateModification = z.object({ + name: z.string(), + type: z.string().nullable().optional(), + text: z.string().nullable().optional(), + color: z.string().nullable().optional(), + image_url: z.string().nullable().optional(), +}); +export type BannerbearTemplateModification = z.infer< + typeof BannerbearTemplateModification +>; + +export const BannerbearTemplate = z.object({ + uid: z.string(), + name: z.string(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + preview_url: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + modifications: z.array(BannerbearTemplateModification).optional(), + self: z.string().nullable().optional(), +}); +export type BannerbearTemplate = z.infer; + +export const BannerbearImage = z.object({ + uid: z.string(), + status: z.string(), + template: z.string().nullable().optional(), + files: z + .object({ + png: z.string().nullable().optional(), + jpg: z.string().nullable().optional(), + pdf: z.string().nullable().optional(), + webp: z.string().nullable().optional(), + avif: z.string().nullable().optional(), + }) + .nullable() + .optional(), + metadata: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearImage = z.infer; + +export const BannerbearVideo = z.object({ + uid: z.string(), + status: z.string(), + video_url: z.string().nullable().optional(), + preview_url: z.string().nullable().optional(), + percent_rendered: z.number().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearVideo = z.infer; + +export const BannerbearAnimatedGif = z.object({ + uid: z.string(), + status: z.string(), + image_url: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearAnimatedGif = z.infer; + +export const BannerbearCollection = z.object({ + uid: z.string(), + status: z.string(), + template_set: z.string().nullable().optional(), + images: z.array(z.string()).nullable().optional(), + image_urls: z.record(z.string(), z.string().nullable()).nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearCollection = z.infer; + +export const BannerbearScreenshot = z.object({ + uid: z.string(), + status: z.string(), + screenshot_url: z.string().nullable().optional(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearScreenshot = z.infer; + +export const BannerbearTemplateSet = z.object({ + uid: z.string(), + name: z.string(), + templates: z.array(z.string()).nullable().optional(), + available_modifications: z + .array(BannerbearTemplateModification) + .nullable() + .optional(), + created_at: z.string().nullable().optional(), + self: z.string().nullable().optional(), +}); +export type BannerbearTemplateSet = z.infer; + +export const BannerbearVideoTemplate = z.object({ + uid: z.string(), + name: z.string(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + created_at: z.string().nullable().optional(), + updated_at: z.string().nullable().optional(), + self: z.string().nullable().optional(), +}); +export type BannerbearVideoTemplate = z.infer; + +export const BannerbearWorkflow = z.object({ + uid: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), + inputs: z.record(z.string(), z.unknown()).nullable().optional(), + steps: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + ui_write_access: z.string().nullable().optional(), + api_write_access: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), +}); +export type BannerbearWorkflow = z.infer; + +export const BannerbearWorkflowRun = z.object({ + uid: z.string(), + status: z.string(), + workflow: z.string().nullable().optional(), + progress: z.number().nullable().optional(), + inputs: z.record(z.string(), z.unknown()).nullable().optional(), + outputs: z.record(z.string(), z.unknown()).nullable().optional(), + steps: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + error: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + completed_at: z.string().nullable().optional(), +}); +export type BannerbearWorkflowRun = z.infer; + +export const BannerbearWebhookObj = z.object({ + uid: z.string(), + url: z.string(), + event: z.string().nullable().optional(), + active: z.boolean().nullable().optional(), + created_at: z.string().nullable().optional(), +}); +export type BannerbearWebhookObj = z.infer; + +export const BannerbearSignedBase = z.object({ + base_url: z.string(), + template: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), +}); +export type BannerbearSignedBase = z.infer; + +export const BannerbearFont = z.object({ + name: z.string(), + family: z.string().nullable().optional(), + category: z.string().nullable().optional(), +}); +export type BannerbearFont = z.infer; + +export const BannerbearEffect = z.object({ + name: z.string(), + slug: z.string().nullable().optional(), +}); +export type BannerbearEffect = z.infer; + +export const BannerbearPdfJoin = z.object({ + uid: z.string(), + status: z.string(), + joined_pdf_url: z.string().nullable().optional(), + self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), +}); +export type BannerbearPdfJoin = z.infer; diff --git a/packages/bannerbear/schema/index.ts b/packages/bannerbear/schema/index.ts new file mode 100644 index 000000000..0be654a3c --- /dev/null +++ b/packages/bannerbear/schema/index.ts @@ -0,0 +1,44 @@ +import { + BannerbearAccount, + BannerbearAnimatedGif, + BannerbearCollection, + BannerbearEffect, + BannerbearFont, + BannerbearImage, + BannerbearPdfJoin, + BannerbearProject, + BannerbearScreenshot, + BannerbearSignedBase, + BannerbearTemplate, + BannerbearTemplateModification, + BannerbearTemplateSet, + BannerbearVideo, + BannerbearVideoTemplate, + BannerbearWebhookObj, + BannerbearWorkflow, + BannerbearWorkflowRun, +} from './database'; + +export const BannerbearSchema = { + version: '1.0.0', + entities: { + account: BannerbearAccount, + project: BannerbearProject, + template: BannerbearTemplate, + templateModification: BannerbearTemplateModification, + image: BannerbearImage, + video: BannerbearVideo, + animatedGif: BannerbearAnimatedGif, + collection: BannerbearCollection, + screenshot: BannerbearScreenshot, + templateSet: BannerbearTemplateSet, + videoTemplate: BannerbearVideoTemplate, + workflow: BannerbearWorkflow, + workflowRun: BannerbearWorkflowRun, + webhookObj: BannerbearWebhookObj, + signedBase: BannerbearSignedBase, + font: BannerbearFont, + effect: BannerbearEffect, + pdfJoin: BannerbearPdfJoin, + }, +} as const; diff --git a/packages/bannerbear/tsconfig.json b/packages/bannerbear/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/bannerbear/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/bannerbear/tsup.config.ts b/packages/bannerbear/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/bannerbear/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/bannerbear/webhooks/image-completed.ts b/packages/bannerbear/webhooks/image-completed.ts new file mode 100644 index 000000000..16e1b2825 --- /dev/null +++ b/packages/bannerbear/webhooks/image-completed.ts @@ -0,0 +1,35 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearWebhooks } from '..'; +import { + createBannerbearImageCompletedMatch, + verifyBannerbearWebhookSignature, +} from './types'; + +export const imageCompleted: BannerbearWebhooks['imageCompleted'] = { + match: createBannerbearImageCompletedMatch(), + + handler: async (ctx, request) => { + const verification = verifyBannerbearWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.status !== 'completed') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'bannerbear.webhook.image_completed', + { uid: event.uid }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; diff --git a/packages/bannerbear/webhooks/index.ts b/packages/bannerbear/webhooks/index.ts new file mode 100644 index 000000000..da7316cc7 --- /dev/null +++ b/packages/bannerbear/webhooks/index.ts @@ -0,0 +1,13 @@ +import { imageCompleted } from './image-completed'; +import { videoCompleted } from './video-completed'; + +export const ImageWebhooks = { + imageCompleted, +}; + +export const VideoWebhooks = { + videoCompleted, +}; + +export * from './tenant-matcher'; +export * from './types'; diff --git a/packages/bannerbear/webhooks/tenant-matcher.ts b/packages/bannerbear/webhooks/tenant-matcher.ts new file mode 100644 index 000000000..c02bd942c --- /dev/null +++ b/packages/bannerbear/webhooks/tenant-matcher.ts @@ -0,0 +1,11 @@ +import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; + +// Bannerbear webhook payloads contain the completed resource objects (e.g. image, video) +// rather than an owning account ID. Routing uses the per-webhook URL configured +// at the project level or per-request callback URL. +// See https://developers.bannerbear.com/v5/#webhooks +export function matchBannerbearTenantWebhook( + _request: RawWebhookRequest, +): WebhookTenantMatch | null { + return null; +} diff --git a/packages/bannerbear/webhooks/types.ts b/packages/bannerbear/webhooks/types.ts new file mode 100644 index 000000000..6bf6eba8a --- /dev/null +++ b/packages/bannerbear/webhooks/types.ts @@ -0,0 +1,113 @@ +import type { + CorsairWebhookMatcher, + RawWebhookRequest, + WebhookRequest, +} from 'corsair/core'; +import { z } from 'zod'; + +// Bannerbear webhook payloads are the completed resource objects themselves. +// When an image/video/collection completes, Bannerbear POSTs the final object +// to the webhook_url that was provided at creation time. + +export const BannerbearWebhookPayloadSchema = z + .object({ + uid: z.string(), + status: z.string().optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + }) + .loose(); + +export type BannerbearWebhookPayload = z.infer< + typeof BannerbearWebhookPayloadSchema +>; + +// ─── Image Completed Event ─────────────────────────────────── +export const ImageCompletedEventSchema = z + .object({ + uid: z.string(), + status: z.literal('completed'), + template: z.string().optional(), + files: z + .object({ + png: z.string().optional(), + jpg: z.string().optional(), + pdf: z.string().optional(), + }) + .optional(), + metadata: z.string().optional(), + self: z.string().optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + }) + .loose(); + +export type ImageCompletedEvent = z.infer; + +// ─── Video Completed Event ─────────────────────────────────── +export const VideoCompletedEventSchema = z + .object({ + uid: z.string(), + status: z.literal('completed'), + video_url: z.string().optional(), + preview_url: z.string().optional(), + percent_rendered: z.number().optional(), + self: z.string().optional(), + created_at: z.string().optional(), + completed_at: z.string().optional(), + }) + .loose(); + +export type VideoCompletedEvent = z.infer; + +// ─── Aggregate types ───────────────────────────────────────── +export type BannerbearWebhookOutputs = { + imageCompleted: ImageCompletedEvent; + videoCompleted: VideoCompletedEvent; +}; + +// ─── Matchers & Verification ───────────────────────────────── +function parseBody(body: unknown): Record | null { + if (typeof body === 'string') { + try { + const parsed = JSON.parse(body); + return parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } + } + return body !== null && typeof body === 'object' && !Array.isArray(body) + ? (body as Record) + : null; +} + +export function createBannerbearImageCompletedMatch(): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + if (!parsedBody) return false; + // Image completed: has status=completed and files field (images have files, videos have video_url) + return parsedBody.status === 'completed' && 'files' in parsedBody; + }; +} + +export function createBannerbearVideoCompletedMatch(): CorsairWebhookMatcher { + return (request: RawWebhookRequest) => { + const parsedBody = parseBody(request.body); + if (!parsedBody) return false; + // Video completed: has status=completed and video_url field + return parsedBody.status === 'completed' && 'video_url' in parsedBody; + }; +} + +export function verifyBannerbearWebhookSignature( + _request: WebhookRequest, + _secret: string, +): { valid: boolean; error?: string } { + // Bannerbear does not use webhook signature verification. + // Webhooks are secured by keeping the webhook URL secret. + return { valid: true }; +} diff --git a/packages/bannerbear/webhooks/video-completed.ts b/packages/bannerbear/webhooks/video-completed.ts new file mode 100644 index 000000000..93953307f --- /dev/null +++ b/packages/bannerbear/webhooks/video-completed.ts @@ -0,0 +1,35 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearWebhooks } from '..'; +import { + createBannerbearVideoCompletedMatch, + verifyBannerbearWebhookSignature, +} from './types'; + +export const videoCompleted: BannerbearWebhooks['videoCompleted'] = { + match: createBannerbearVideoCompletedMatch(), + + handler: async (ctx, request) => { + const verification = verifyBannerbearWebhookSignature(request, ctx.key); + if (!verification.valid) { + return { + success: false, + statusCode: 401, + error: verification.error || 'Signature verification failed', + }; + } + + const event = request.payload; + if (event.status !== 'completed') { + return { success: true, data: undefined }; + } + + await logEventFromContext( + ctx, + 'bannerbear.webhook.video_completed', + { uid: event.uid }, + 'completed', + ); + + return { success: true, data: event }; + }, +}; From 675f40bccbf04199e80a413a38ecae18817aa358 Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 10:10:29 +0530 Subject: [PATCH 02/11] feat(corsair): register bannerbear in providers --- packages/corsair/core/constants.ts | 9 +++-- pnpm-lock.yaml | 53 ++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 509d644f2..3f1e290c7 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -28,10 +28,10 @@ export const BaseProviders = [ 'agenty', 'ahrefs', 'aimlapi', - 'allimagesai', 'airtable', 'alchemy', 'algolia', + 'allimagesai', 'alphavantage', 'altoviz', 'alttextai', @@ -56,6 +56,7 @@ export const BaseProviders = [ 'asyncinterview', 'attio', 'ayrshare', + 'bannerbear', 'basecamp', 'baselinker', 'basin', @@ -205,10 +206,10 @@ export const ProviderDisplayNames = { agenty: 'Agenty', ahrefs: 'Ahrefs', aimlapi: 'AI/ML API', - allimagesai: 'All Images AI', airtable: 'Airtable', alchemy: 'Alchemy', algolia: 'Algolia', + allimagesai: 'All Images AI', alphavantage: 'Alpha Vantage', altoviz: 'Altoviz', alttextai: 'AltText.ai', @@ -233,6 +234,7 @@ export const ProviderDisplayNames = { asyncinterview: 'Async Interview', attio: 'Attio', ayrshare: 'Ayrshare', + bannerbear: 'Bannerbear', basecamp: 'Basecamp', baselinker: 'BaseLinker', basin: 'Basin', @@ -389,10 +391,10 @@ export type AllProviders = | 'agenty' | 'ahrefs' | 'aimlapi' - | 'allimagesai' | 'airtable' | 'alchemy' | 'algolia' + | 'allimagesai' | 'alphavantage' | 'altoviz' | 'alttextai' @@ -417,6 +419,7 @@ export type AllProviders = | 'asyncinterview' | 'attio' | 'ayrshare' + | 'bannerbear' | 'basecamp' | 'baselinker' | 'basin' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18845437d..f779be730 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,6 +165,9 @@ importers: '@corsair-dev/agentql': specifier: workspace:* version: link:../../packages/agentql + '@corsair-dev/bannerbear': + specifier: workspace:* + version: link:../../packages/bannerbear '@corsair-dev/bitwarden': specifier: workspace:* version: link:../../packages/bitwarden @@ -737,7 +740,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alphavantage: + packages/allimagesai: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -761,7 +764,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/altoviz: + packages/alphavantage: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -785,7 +788,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/alttextai: + packages/altoviz: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -793,9 +796,6 @@ importers: corsair: specifier: workspace:* version: link:../corsair - dotenv: - specifier: ^17.2.3 - version: 17.4.2 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -812,17 +812,17 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/amara: + packages/alttextai: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 - '@types/node': - specifier: ^24.10.1 - version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair + dotenv: + specifier: ^17.2.3 + version: 17.4.2 jest: specifier: ^29.7.0 version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) @@ -839,11 +839,14 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambee: + packages/amara: devDependencies: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 corsair: specifier: workspace:* version: link:../corsair @@ -863,7 +866,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/ambientweather: + packages/ambee: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -887,7 +890,7 @@ importers: specifier: 4.4.3 version: 4.4.3 - packages/allimagesai: + packages/ambientweather: devDependencies: '@types/jest': specifier: ^29.5.14 @@ -1384,6 +1387,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/bannerbear: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/basecamp: devDependencies: '@types/jest': From 536c6fce21735467ececcea6745b17c07f87dabb Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 11:47:55 +0530 Subject: [PATCH 03/11] fix(bannerbear): unauthenticated webhook requests close failed --- packages/bannerbear/webhooks/types.ts | 60 ++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/packages/bannerbear/webhooks/types.ts b/packages/bannerbear/webhooks/types.ts index 6bf6eba8a..7f4806252 100644 --- a/packages/bannerbear/webhooks/types.ts +++ b/packages/bannerbear/webhooks/types.ts @@ -3,6 +3,7 @@ import type { RawWebhookRequest, WebhookRequest, } from 'corsair/core'; +import { verifyHmacSignature } from 'corsair/http'; import { z } from 'zod'; // Bannerbear webhook payloads are the completed resource objects themselves. @@ -104,10 +105,59 @@ export function createBannerbearVideoCompletedMatch(): CorsairWebhookMatcher { } export function verifyBannerbearWebhookSignature( - _request: WebhookRequest, - _secret: string, + request: WebhookRequest, + webhookSecret?: string, ): { valid: boolean; error?: string } { - // Bannerbear does not use webhook signature verification. - // Webhooks are secured by keeping the webhook URL secret. - return { valid: true }; + if (request.hubVerified) { + return { valid: true }; + } + + if (!webhookSecret) { + return { valid: false, error: 'Missing webhook secret' }; + } + + const headers = request.headers; + + // 1. Check Bannerbear Webhook Key in Authorization header (Bannerbear native) + const authHeader = Array.isArray(headers.authorization) + ? headers.authorization[0] + : headers.authorization; + + if (authHeader) { + const token = authHeader.startsWith('Bearer ') + ? authHeader.slice(7).trim() + : authHeader.trim(); + if (token === webhookSecret) { + return { valid: true }; + } + } + + // 2. Check HMAC signature header (x-bannerbear-signature / signature) + const signature = Array.isArray(headers['x-bannerbear-signature']) + ? headers['x-bannerbear-signature'][0] + : headers['x-bannerbear-signature'] || + (Array.isArray(headers.signature) + ? headers.signature[0] + : headers.signature); + + if (signature && request.rawBody) { + const isValid = verifyHmacSignature( + request.rawBody, + webhookSecret, + signature, + 'sha256', + ); + if (isValid) { + return { valid: true }; + } + } + + if (!authHeader && !signature) { + return { + valid: false, + error: 'Missing Authorization or x-bannerbear-signature header', + }; + } + + return { valid: false, error: 'Invalid webhook credentials or signature' }; } From 538365fcddc2013e083df9f68b3fbcd74d94ec26 Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 11:49:33 +0530 Subject: [PATCH 04/11] fix(bannerbear): propagate correct error messages --- packages/bannerbear/client.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/bannerbear/client.ts b/packages/bannerbear/client.ts index 7c00dc527..ff0f17866 100644 --- a/packages/bannerbear/client.ts +++ b/packages/bannerbear/client.ts @@ -1,5 +1,5 @@ import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; -import { request } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; export class BannerbearAPIError extends Error { constructor( @@ -50,6 +50,9 @@ export async function makeBannerbearRequest( try { return await request(config, requestOptions); } catch (error) { + if (error instanceof ApiError) { + throw error; + } if (error instanceof Error) { throw new BannerbearAPIError(error.message); } From a600625910e05db626b7a853dd934d961a594ec7 Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 11:50:39 +0530 Subject: [PATCH 05/11] feat(bannerbear): Webhook tests --- packages/bannerbear/webhooks.test.ts | 227 +++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 packages/bannerbear/webhooks.test.ts diff --git a/packages/bannerbear/webhooks.test.ts b/packages/bannerbear/webhooks.test.ts new file mode 100644 index 000000000..6c602dfbe --- /dev/null +++ b/packages/bannerbear/webhooks.test.ts @@ -0,0 +1,227 @@ +import type { RawWebhookRequest, WebhookRequest } from 'corsair/core'; +import * as crypto from 'crypto'; +import type { BannerbearContext } from './index'; +import { bannerbear } from './index'; +import { + createBannerbearImageCompletedMatch, + createBannerbearVideoCompletedMatch, + verifyBannerbearWebhookSignature, +} from './webhooks/types'; + +jest.mock('corsair/core', () => { + const original = jest.requireActual('corsair/core'); + return { + ...original, + logEventFromContext: jest.fn().mockResolvedValue('mock-event-id'), + }; +}); + +const mockCtx = { + key: 'bb_test_api_key_123', + $getAccountId: () => 'test-account-id', + options: {}, + keys: { + get_api_key: jest.fn().mockResolvedValue('bb_test_api_key_123'), + }, + logEvent: jest.fn(), + database: {}, +} as unknown as BannerbearContext; + +describe('Bannerbear webhooks verification and handlers', () => { + const plugin = bannerbear(); + const webhooks = plugin.webhooks!; + const secret = 'webhook_secret_key_xyz'; + + function createSignedWebhookRequest( + payloadObj: Record, + signingSecret = secret, + ): WebhookRequest> { + const rawBody = JSON.stringify(payloadObj); + const signature = crypto + .createHmac('sha256', signingSecret) + .update(rawBody) + .digest('hex'); + + return { + rawBody, + payload: payloadObj, + headers: { + 'x-bannerbear-signature': signature, + }, + }; + } + + it('verifies valid HMAC signature successfully', () => { + const request = createSignedWebhookRequest({ + uid: 'img_123', + status: 'completed', + }); + const result = verifyBannerbearWebhookSignature(request, secret); + expect(result.valid).toBe(true); + }); + + it('verifies valid Authorization Bearer webhook key successfully', () => { + const request: WebhookRequest> = { + rawBody: JSON.stringify({ uid: 'img_123', status: 'completed' }), + payload: { uid: 'img_123', status: 'completed' }, + headers: { + authorization: `Bearer ${secret}`, + }, + }; + const result = verifyBannerbearWebhookSignature(request, secret); + expect(result.valid).toBe(true); + }); + + it('accepts hubVerified requests even without secret', () => { + const request: WebhookRequest> = { + rawBody: JSON.stringify({ uid: 'img_123' }), + payload: { uid: 'img_123' }, + headers: {}, + hubVerified: true, + }; + const result = verifyBannerbearWebhookSignature(request, undefined); + expect(result.valid).toBe(true); + }); + + it('rejects invalid signature', () => { + const request = createSignedWebhookRequest({ + uid: 'img_123', + status: 'completed', + }); + const result = verifyBannerbearWebhookSignature(request, 'wrong_secret'); + expect(result.valid).toBe(false); + expect(result.error).toBe('Invalid webhook credentials or signature'); + }); + + it('rejects request when webhook secret is missing', () => { + const request = createSignedWebhookRequest({ + uid: 'img_123', + status: 'completed', + }); + const result = verifyBannerbearWebhookSignature(request, undefined); + expect(result.valid).toBe(false); + expect(result.error).toBe('Missing webhook secret'); + }); + + it('rejects request without signature or authorization header', () => { + const request: WebhookRequest> = { + rawBody: JSON.stringify({ uid: 'img_123' }), + payload: { uid: 'img_123' }, + headers: {}, + }; + const result = verifyBannerbearWebhookSignature(request, secret); + expect(result.valid).toBe(false); + expect(result.error).toBe( + 'Missing Authorization or x-bannerbear-signature header', + ); + }); + + it('processes imageCompleted webhook handler with valid signature', async () => { + const webhookCtx = { ...mockCtx, key: secret }; + const payload = { + uid: 'img_123', + status: 'completed' as const, + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }; + const req = createSignedWebhookRequest(payload) as unknown as Parameters< + typeof webhooks.image.imageCompleted.handler + >[1]; + + const res = await webhooks.image.imageCompleted.handler(webhookCtx, req); + expect(res.success).toBe(true); + expect(res.data).toEqual(payload); + }); + + it('rejects imageCompleted handler when signature verification fails', async () => { + const webhookCtx = { ...mockCtx, key: secret }; + const payload = { + uid: 'img_123', + status: 'completed' as const, + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }; + const req = createSignedWebhookRequest( + payload, + 'wrong_secret', + ) as unknown as Parameters[1]; + + const res = await webhooks.image.imageCompleted.handler(webhookCtx, req); + expect(res.success).toBe(false); + expect(res.statusCode).toBe(401); + }); + + it('processes videoCompleted webhook handler with valid signature', async () => { + const webhookCtx = { ...mockCtx, key: secret }; + const payload = { + uid: 'vid_123', + status: 'completed' as const, + video_url: 'https://cdn.bannerbear.com/sample.mp4', + }; + const req = createSignedWebhookRequest(payload) as unknown as Parameters< + typeof webhooks.video.videoCompleted.handler + >[1]; + + const res = await webhooks.video.videoCompleted.handler(webhookCtx, req); + expect(res.success).toBe(true); + expect(res.data).toEqual(payload); + }); + + it('matches image completed webhook payloads', () => { + const matcher = createBannerbearImageCompletedMatch(); + + const validImagePayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'completed', + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }), + }; + + const pendingPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'pending', + files: null, + }), + }; + + const videoPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'vid_123', + status: 'completed', + video_url: 'https://cdn.bannerbear.com/sample.mp4', + }), + }; + + expect(matcher(validImagePayload)).toBe(true); + expect(matcher(pendingPayload)).toBe(false); + expect(matcher(videoPayload)).toBe(false); + }); + + it('matches video completed webhook payloads', () => { + const matcher = createBannerbearVideoCompletedMatch(); + + const validVideoPayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'vid_123', + status: 'completed', + video_url: 'https://cdn.bannerbear.com/sample.mp4', + }), + }; + + const imagePayload: RawWebhookRequest = { + headers: {}, + body: JSON.stringify({ + uid: 'img_123', + status: 'completed', + files: { png: 'https://cdn.bannerbear.com/sample.png' }, + }), + }; + + expect(matcher(validVideoPayload)).toBe(true); + expect(matcher(imagePayload)).toBe(false); + }); +}); From 3e6a432c1a2286df2b954d896364e67f8abbe532 Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 11:51:37 +0530 Subject: [PATCH 06/11] fix(bannerbear): add webhook api's tests --- packages/bannerbear/endpoints.test.ts | 722 ++++++++++++++++++++++++-- 1 file changed, 671 insertions(+), 51 deletions(-) diff --git a/packages/bannerbear/endpoints.test.ts b/packages/bannerbear/endpoints.test.ts index e3ca37b63..c6daeec6e 100644 --- a/packages/bannerbear/endpoints.test.ts +++ b/packages/bannerbear/endpoints.test.ts @@ -1,9 +1,36 @@ -import type { RawWebhookRequest } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import { makeBannerbearRequest } from './client'; +import type { BannerbearContext } from './index'; import { bannerbear, bannerbearEndpointSchemas } from './index'; -import { - createBannerbearImageCompletedMatch, - createBannerbearVideoCompletedMatch, -} from './webhooks/types'; + +jest.mock('corsair/core', () => { + const original = jest.requireActual('corsair/core'); + return { + ...original, + logEventFromContext: jest.fn().mockResolvedValue('mock-event-id'), + }; +}); + +jest.mock('corsair/http', () => { + const original = jest.requireActual('corsair/http'); + return { + ...original, + request: jest.fn(), + }; +}); + +const mockRequest = request as jest.Mock; + +const mockCtx = { + key: 'bb_test_api_key_123', + $getAccountId: () => 'test-account-id', + options: {}, + keys: { + get_api_key: jest.fn().mockResolvedValue('bb_test_api_key_123'), + }, + logEvent: jest.fn(), + database: {}, +} as unknown as BannerbearContext; describe('Bannerbear plugin registry', () => { const plugin = bannerbear(); @@ -37,7 +64,7 @@ describe('Bannerbear plugin registry', () => { }); it('defines input and output schemas for every endpoint', () => { - for (const [path, schema] of Object.entries(bannerbearEndpointSchemas)) { + for (const [_path, schema] of Object.entries(bannerbearEndpointSchemas)) { expect(schema.input).toBeDefined(); expect(schema.output).toBeDefined(); } @@ -56,64 +83,657 @@ describe('Bannerbear plugin registry', () => { }); }); -describe('Bannerbear webhooks', () => { - it('matches image completed webhook payloads', () => { - const matcher = createBannerbearImageCompletedMatch(); +describe('Bannerbear client error wrapping', () => { + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('rethrows ApiError directly without dropping status and retry metadata', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + ); + mockRequest.mockRejectedValue(apiError); + + await expect( + makeBannerbearRequest('/v5/images', 'test-key', { method: 'GET' }), + ).rejects.toThrow(apiError); + }); + + it('wraps generic errors in BannerbearAPIError', async () => { + mockRequest.mockRejectedValue(new Error('Network error')); + + await expect( + makeBannerbearRequest('/v5/images', 'test-key', { method: 'GET' }), + ).rejects.toMatchObject({ + name: 'BannerbearAPIError', + message: 'Network error', + }); + }); +}); + +describe('Bannerbear endpoint handler invocations', () => { + const plugin = bannerbear(); + const endpoints = plugin.endpoints!; + + beforeEach(() => { + mockRequest.mockReset(); + }); + + // Account + it('invokes account.getAccountInfo', async () => { + mockRequest.mockResolvedValue({ uid: 'acc_1', plan: 'pro' }); + const result = await endpoints.account.getAccountInfo(mockCtx, { + project_id: 'proj_1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://api.bannerbear.com', + HEADERS: expect.objectContaining({ + Authorization: 'Bearer bb_test_api_key_123', + }), + }), + expect.objectContaining({ + method: 'GET', + url: '/v5/account', + query: { project_id: 'proj_1' }, + }), + ); + expect(result).toEqual({ uid: 'acc_1', plan: 'pro' }); + }); + + it('invokes account.getAuth', async () => { + mockRequest.mockResolvedValue({ uid: 'acc_1' }); + const result = await endpoints.account.getAuth(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/account', + }), + ); + expect(result).toEqual({ uid: 'acc_1' }); + }); + + // Projects + it('invokes projects.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'proj_1', name: 'Project 1' }]); + const result = await endpoints.projects.list(mockCtx, { page: 2 }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/projects', + query: { page: 2 }, + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes projects.get', async () => { + mockRequest.mockResolvedValue({ uid: 'proj_1', name: 'Project 1' }); + const result = await endpoints.projects.get(mockCtx, { uid: 'proj_1' }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/projects/proj_1', + }), + ); + expect(result.name).toBe('Project 1'); + }); + + it('invokes projects.create', async () => { + mockRequest.mockResolvedValue({ uid: 'proj_new', name: 'New Project' }); + const result = await endpoints.projects.create(mockCtx, { + name: 'New Project', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/projects', + body: { name: 'New Project' }, + }), + ); + expect(result.uid).toBe('proj_new'); + }); + + it('invokes projects.hydrate', async () => { + mockRequest.mockResolvedValue({ uid: 'proj_1', name: 'Hydrated' }); + const result = await endpoints.projects.hydrate(mockCtx, { + uid: 'proj_1', + source_project: 'src_proj', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/projects/proj_1/hydrate', + body: { source_project: 'src_proj' }, + }), + ); + expect(result.uid).toBe('proj_1'); + }); + + // Templates + it('invokes templates.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'tmpl_1', name: 'Template 1' }]); + const result = await endpoints.templates.list(mockCtx, { + page: 1, + limit: 10, + project_id: 'p1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/templates', + query: { page: 1, limit: 10, project_id: 'p1' }, + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes templates.get', async () => { + mockRequest.mockResolvedValue({ uid: 'tmpl_1', name: 'Template 1' }); + const result = await endpoints.templates.get(mockCtx, { + uid: 'tmpl_1', + project_id: 'p1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/templates/tmpl_1', + query: { project_id: 'p1' }, + }), + ); + expect(result.name).toBe('Template 1'); + }); + + it('invokes templates.create', async () => { + mockRequest.mockResolvedValue({ + uid: 'tmpl_2', + name: 'T2', + width: 1200, + height: 630, + }); + const result = await endpoints.templates.create(mockCtx, { + name: 'T2', + width: 1200, + height: 630, + project_id: 'p1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/templates', + body: { name: 'T2', width: 1200, height: 630, project_id: 'p1' }, + }), + ); + expect(result.width).toBe(1200); + }); + + it('invokes templates.delete', async () => { + mockRequest.mockResolvedValue(undefined); + const result = await endpoints.templates.delete(mockCtx, { + uid: 'tmpl_1', + project_id: 'p1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'DELETE', + url: '/v5/templates/tmpl_1', + query: { project_id: 'p1' }, + }), + ); + expect(result).toEqual({ success: true }); + }); + + it('invokes templates.import', async () => { + mockRequest.mockResolvedValue({ uid: 'tmpl_imp', name: 'Imported' }); + const result = await endpoints.templates.import(mockCtx, { + publication_id: 'pub_123', + project_id: 'p1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/templates/import', + body: { publication_id: 'pub_123', project_id: 'p1' }, + }), + ); + expect(result.uid).toBe('tmpl_imp'); + }); + + // Template Sets + it('invokes templateSets.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'ts_1', name: 'Set 1' }]); + const result = await endpoints.templateSets.list(mockCtx, { page: 1 }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/template_sets', + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes templateSets.get', async () => { + mockRequest.mockResolvedValue({ uid: 'ts_1', name: 'Set 1' }); + const result = await endpoints.templateSets.get(mockCtx, { uid: 'ts_1' }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/template_sets/ts_1', + }), + ); + expect(result.uid).toBe('ts_1'); + }); + + it('invokes templateSets.create', async () => { + mockRequest.mockResolvedValue({ uid: 'ts_new', name: 'Set New' }); + const result = await endpoints.templateSets.create(mockCtx, { + name: 'Set New', + templates: ['tmpl_1', 'tmpl_2'], + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/template_sets', + body: { name: 'Set New', templates: ['tmpl_1', 'tmpl_2'] }, + }), + ); + expect(result.uid).toBe('ts_new'); + }); + + it('invokes templateSets.update', async () => { + mockRequest.mockResolvedValue({ uid: 'ts_1', name: 'Set 1' }); + const result = await endpoints.templateSets.update(mockCtx, { + uid: 'ts_1', + templates: ['tmpl_3'], + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'PATCH', + url: '/v5/template_sets/ts_1', + body: { templates: ['tmpl_3'] }, + }), + ); + expect(result.uid).toBe('ts_1'); + }); + + // Images + it('invokes images.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'img_1', status: 'completed' }]); + const result = await endpoints.images.list(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/images', + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes images.get', async () => { + mockRequest.mockResolvedValue({ uid: 'img_1', status: 'completed' }); + const result = await endpoints.images.get(mockCtx, { uid: 'img_1' }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/images/img_1', + }), + ); + expect(result.uid).toBe('img_1'); + }); + + // Videos + it('invokes videos.listVideos', async () => { + mockRequest.mockResolvedValue([{ uid: 'vid_1', status: 'completed' }]); + const result = await endpoints.videos.listVideos(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/videos', + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes videos.listVideoTemplates', async () => { + mockRequest.mockResolvedValue([{ uid: 'vt_1', name: 'VT 1' }]); + const result = await endpoints.videos.listVideoTemplates(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/video_templates', + }), + ); + expect(result).toHaveLength(1); + }); - const validImagePayload: RawWebhookRequest = { - headers: {}, - body: JSON.stringify({ - uid: 'img_123', - status: 'completed', - files: { png: 'https://cdn.bannerbear.com/sample.png' }, + it('invokes videos.createVideoTemplate', async () => { + mockRequest.mockResolvedValue({ uid: 'vt_new', name: 'New VT' }); + const result = await endpoints.videos.createVideoTemplate(mockCtx, { + name: 'New VT', + template: 'tmpl_1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/video_templates', + body: { name: 'New VT', template: 'tmpl_1' }, }), - }; + ); + expect(result.uid).toBe('vt_new'); + }); - const pendingPayload: RawWebhookRequest = { - headers: {}, - body: JSON.stringify({ - uid: 'img_123', - status: 'pending', - files: null, + // Animations + it('invokes animations.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'gif_1', status: 'completed' }]); + const result = await endpoints.animations.list(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/animated_gifs', }), - }; + ); + expect(result).toHaveLength(1); + }); - const videoPayload: RawWebhookRequest = { - headers: {}, - body: JSON.stringify({ - uid: 'vid_123', - status: 'completed', - video_url: 'https://cdn.bannerbear.com/sample.mp4', + it('invokes animations.get', async () => { + mockRequest.mockResolvedValue({ uid: 'gif_1', status: 'completed' }); + const result = await endpoints.animations.get(mockCtx, { uid: 'gif_1' }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/animated_gifs/gif_1', }), - }; + ); + expect(result.uid).toBe('gif_1'); + }); - expect(matcher(validImagePayload)).toBe(true); - expect(matcher(pendingPayload)).toBe(false); - expect(matcher(videoPayload)).toBe(false); + // Collections + it('invokes collections.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'col_1', status: 'completed' }]); + const result = await endpoints.collections.list(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/collections', + }), + ); + expect(result).toHaveLength(1); }); - it('matches video completed webhook payloads', () => { - const matcher = createBannerbearVideoCompletedMatch(); + // Screenshots + it('invokes screenshots.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'ss_1', status: 'completed' }]); + const result = await endpoints.screenshots.list(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/screenshots', + }), + ); + expect(result).toHaveLength(1); + }); - const validVideoPayload: RawWebhookRequest = { - headers: {}, - body: JSON.stringify({ - uid: 'vid_123', - status: 'completed', - video_url: 'https://cdn.bannerbear.com/sample.mp4', + it('invokes screenshots.get', async () => { + mockRequest.mockResolvedValue({ uid: 'ss_1', status: 'completed' }); + const result = await endpoints.screenshots.get(mockCtx, { uid: 'ss_1' }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/screenshots/ss_1', }), - }; + ); + expect(result.uid).toBe('ss_1'); + }); - const imagePayload: RawWebhookRequest = { - headers: {}, - body: JSON.stringify({ - uid: 'img_123', - status: 'completed', - files: { png: 'https://cdn.bannerbear.com/sample.png' }, + // Signed URLs + it('invokes signedUrls.getSignedBases', async () => { + mockRequest.mockResolvedValue([ + { base_url: 'https://on-demand.bannerbear.com/taggedurl/xxx' }, + ]); + const result = await endpoints.signedUrls.getSignedBases(mockCtx, { + uid: 'tmpl_1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/templates/tmpl_1/signed_bases', }), - }; + ); + expect(result).toHaveLength(1); + }); - expect(matcher(validVideoPayload)).toBe(true); - expect(matcher(imagePayload)).toBe(false); + it('invokes signedUrls.createSignedBase', async () => { + mockRequest.mockResolvedValue({ + base_url: 'https://on-demand.bannerbear.com/taggedurl/xxx', + }); + const result = await endpoints.signedUrls.createSignedBase(mockCtx, { + uid: 'tmpl_1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/templates/tmpl_1/signed_bases', + }), + ); + expect(result.base_url).toBeDefined(); + }); + + // Webhooks API + it('invokes webhooksApi.get', async () => { + mockRequest.mockResolvedValue({ uid: 'wh_1', url: 'https://app.com/hook' }); + const result = await endpoints.webhooksApi.get(mockCtx, { uid: 'wh_1' }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/webhooks/wh_1', + }), + ); + expect(result.uid).toBe('wh_1'); + }); + + it('invokes webhooksApi.create', async () => { + mockRequest.mockResolvedValue({ + uid: 'wh_new', + url: 'https://app.com/hook', + }); + const result = await endpoints.webhooksApi.create(mockCtx, { + url: 'https://app.com/hook', + event: 'image.completed', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/webhooks', + body: { url: 'https://app.com/hook', event: 'image.completed' }, + }), + ); + expect(result.uid).toBe('wh_new'); + }); + + it('invokes webhooksApi.delete', async () => { + mockRequest.mockResolvedValue(undefined); + const result = await endpoints.webhooksApi.delete(mockCtx, { uid: 'wh_1' }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'DELETE', + url: '/v5/webhooks/wh_1', + }), + ); + expect(result).toEqual({ success: true }); + }); + + // Misc + it('invokes misc.getFonts', async () => { + mockRequest.mockResolvedValue([{ name: 'Roboto', family: 'sans-serif' }]); + const result = await endpoints.misc.getFonts(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/fonts', + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes misc.listEffects', async () => { + mockRequest.mockResolvedValue([{ name: 'Blur', slug: 'blur' }]); + const result = await endpoints.misc.listEffects(mockCtx, {}); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/effects', + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes misc.joinPdfs', async () => { + mockRequest.mockResolvedValue({ + uid: 'pdf_1', + status: 'completed', + joined_pdf_url: 'https://cdn.com/doc.pdf', + }); + const result = await endpoints.misc.joinPdfs(mockCtx, { + pdf_urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/pdfs', + body: { + pdf_urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], + }, + }), + ); + expect(result.joined_pdf_url).toBeDefined(); + }); + + // Workflows + it('invokes workflows.listWorkflows', async () => { + mockRequest.mockResolvedValue([{ uid: 'wf_1', name: 'Workflow 1' }]); + const result = await endpoints.workflows.listWorkflows(mockCtx, { + page: 1, + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/workflows', + query: { page: 1 }, + }), + ); + expect(result).toHaveLength(1); + }); + + it('invokes workflows.getWorkflow', async () => { + mockRequest.mockResolvedValue({ uid: 'wf_1', name: 'Workflow 1' }); + const result = await endpoints.workflows.getWorkflow(mockCtx, { + uid: 'wf_1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/workflows/wf_1', + }), + ); + expect(result.name).toBe('Workflow 1'); + }); + + it('invokes workflows.createWorkflowRun', async () => { + mockRequest.mockResolvedValue({ + uid: 'wfr_1', + status: 'running', + workflow: 'wf_1', + }); + const result = await endpoints.workflows.createWorkflowRun(mockCtx, { + workflow: 'wf_1', + inputs: { title: 'Test' }, + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'POST', + url: '/v5/workflow_runs', + body: { workflow: 'wf_1', inputs: { title: 'Test' } }, + }), + ); + expect(result.uid).toBe('wfr_1'); + }); + + it('invokes workflows.getWorkflowRun', async () => { + mockRequest.mockResolvedValue({ + uid: 'wfr_1', + status: 'completed', + workflow: 'wf_1', + }); + const result = await endpoints.workflows.getWorkflowRun(mockCtx, { + uid: 'wfr_1', + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/workflow_runs/wfr_1', + }), + ); + expect(result.status).toBe('completed'); + }); + + it('invokes workflows.listWorkflowRuns', async () => { + mockRequest.mockResolvedValue([ + { uid: 'wfr_1', status: 'completed', workflow: 'wf_1' }, + ]); + const result = await endpoints.workflows.listWorkflowRuns(mockCtx, { + page: 1, + }); + expect(mockRequest).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + method: 'GET', + url: '/v5/workflow_runs', + query: { page: 1 }, + }), + ); }); }); From 7a52990925146eb7881aaeaeaf57c6b3cb49b6dd Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 12:04:39 +0530 Subject: [PATCH 07/11] chore: update pnpm-lock.yaml --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f779be730..a5831f1a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,9 +165,6 @@ importers: '@corsair-dev/agentql': specifier: workspace:* version: link:../../packages/agentql - '@corsair-dev/bannerbear': - specifier: workspace:* - version: link:../../packages/bannerbear '@corsair-dev/bitwarden': specifier: workspace:* version: link:../../packages/bitwarden From 14bb24f91ba486bce2ec1143c02b5e0914ea672a Mon Sep 17 00:00:00 2001 From: yuvanvk Date: Sun, 23 Aug 2026 12:17:34 +0530 Subject: [PATCH 08/11] chore(bannerbear): re-trigger ci From 4b60f387f44df0280725b6be08da59071ccc58e5 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Sun, 23 Aug 2026 16:21:42 +0530 Subject: [PATCH 09/11] fix(bannerbear): retry reads in client not binder --- packages/bannerbear/client.ts | 84 +++++++++++++++++++++++---- packages/bannerbear/error-handlers.ts | 2 +- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/bannerbear/client.ts b/packages/bannerbear/client.ts index ff0f17866..916a44eee 100644 --- a/packages/bannerbear/client.ts +++ b/packages/bannerbear/client.ts @@ -1,4 +1,8 @@ -import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; import { ApiError, request } from 'corsair/http'; export class BannerbearAPIError extends Error { @@ -11,7 +15,37 @@ export class BannerbearAPIError extends Error { } } -const BANNERBEAR_API_BASE = 'https://api.bannerbear.com'; +export const BANNERBEAR_API_BASE = 'https://api.bannerbear.com'; + +export function encodeBannerbearUid(uid: string): string { + return encodeURIComponent(uid); +} + +const READ_MAX_ATTEMPTS = 6; + +const NO_RETRY: RateLimitConfig = { + enabled: true, + maxRetries: 0, + initialRetryDelay: 0, + backoffMultiplier: 1, + headerNames: { + retryAfter: 'retry-after', + }, +}; + +function isRetryableBannerbearError(error: unknown): error is ApiError { + if (!(error instanceof ApiError) || error.status === undefined) { + return false; + } + return error.status === 429 || error.status >= 500; +} + +function retryDelayMs(error: ApiError, attempt: number): number { + if (typeof error.retryAfter === 'number' && error.retryAfter >= 0) { + return error.retryAfter; + } + return 2 ** attempt * 1000; +} export async function makeBannerbearRequest( endpoint: string, @@ -20,9 +54,10 @@ export async function makeBannerbearRequest( method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; body?: Record; query?: Record; + retries?: boolean; } = {}, ): Promise { - const { method = 'GET', body, query } = options; + const { method = 'GET', body, query, retries = method === 'GET' } = options; const config: OpenAPIConfig = { BASE: BANNERBEAR_API_BASE, @@ -47,15 +82,42 @@ export async function makeBannerbearRequest( query, }; - try { - return await request(config, requestOptions); - } catch (error) { - if (error instanceof ApiError) { - throw error; + const send = async (): Promise => { + try { + return await request(config, requestOptions, { + rateLimitConfig: NO_RETRY, + }); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + if (error instanceof Error) { + throw new BannerbearAPIError(error.message); + } + throw new BannerbearAPIError('Unknown error'); } - if (error instanceof Error) { - throw new BannerbearAPIError(error.message); + }; + + if (!retries) { + return await send(); + } + + let lastError: unknown; + for (let attempt = 0; attempt < READ_MAX_ATTEMPTS; attempt++) { + try { + return await send(); + } catch (error) { + lastError = error; + if ( + !isRetryableBannerbearError(error) || + attempt === READ_MAX_ATTEMPTS - 1 + ) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, retryDelayMs(error, attempt)), + ); } - throw new BannerbearAPIError('Unknown error'); } + throw lastError; } diff --git a/packages/bannerbear/error-handlers.ts b/packages/bannerbear/error-handlers.ts index 5a4f4c19f..217f52255 100644 --- a/packages/bannerbear/error-handlers.ts +++ b/packages/bannerbear/error-handlers.ts @@ -13,7 +13,7 @@ export const errorHandlers = { if (error instanceof ApiError && error.retryAfter !== undefined) { retryAfterMs = error.retryAfter; } - return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + return { maxRetries: 0, headersRetryAfterMs: retryAfterMs }; }, }, AUTH_ERROR: { From e9ad31cc9470d1e47d9b3ffe5b6b4fa0361c1625 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Sun, 23 Aug 2026 16:21:42 +0530 Subject: [PATCH 10/11] fix(bannerbear): remap plugin to Bannerbear V5 --- packages/bannerbear/endpoints/account.ts | 20 +- .../endpoints/animation-templates.ts | 60 +++ packages/bannerbear/endpoints/animations.ts | 48 +- packages/bannerbear/endpoints/collections.ts | 27 -- packages/bannerbear/endpoints/images.ts | 34 +- packages/bannerbear/endpoints/index.ts | 30 +- packages/bannerbear/endpoints/misc.ts | 30 +- packages/bannerbear/endpoints/projects.ts | 65 --- packages/bannerbear/endpoints/screenshots.ts | 43 -- packages/bannerbear/endpoints/signed-urls.ts | 20 +- .../bannerbear/endpoints/template-sets.ts | 88 ---- packages/bannerbear/endpoints/templates.ts | 39 +- packages/bannerbear/endpoints/types.ts | 420 ++++++------------ packages/bannerbear/endpoints/videos.ts | 66 --- packages/bannerbear/endpoints/webhooks-api.ts | 16 +- packages/bannerbear/endpoints/workflows.ts | 11 +- packages/bannerbear/index.ts | 399 +++++------------ packages/bannerbear/schema/database.ts | 162 ++----- packages/bannerbear/schema/index.ts | 42 +- ...eo-completed.ts => animation-completed.ts} | 8 +- packages/bannerbear/webhooks/index.ts | 6 +- .../bannerbear/webhooks/tenant-matcher.ts | 4 - packages/bannerbear/webhooks/types.ts | 106 +++-- 23 files changed, 553 insertions(+), 1191 deletions(-) create mode 100644 packages/bannerbear/endpoints/animation-templates.ts delete mode 100644 packages/bannerbear/endpoints/collections.ts delete mode 100644 packages/bannerbear/endpoints/projects.ts delete mode 100644 packages/bannerbear/endpoints/screenshots.ts delete mode 100644 packages/bannerbear/endpoints/template-sets.ts delete mode 100644 packages/bannerbear/endpoints/videos.ts rename packages/bannerbear/webhooks/{video-completed.ts => animation-completed.ts} (76%) diff --git a/packages/bannerbear/endpoints/account.ts b/packages/bannerbear/endpoints/account.ts index 09a48e5c5..02df8eb53 100644 --- a/packages/bannerbear/endpoints/account.ts +++ b/packages/bannerbear/endpoints/account.ts @@ -5,35 +5,23 @@ import type { BannerbearEndpointOutputs } from './types'; export const getAccountInfo: BannerbearEndpoints['getAccountInfo'] = async ( ctx, - input, + _input, ) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['getAccountInfo'] >('/v5/account', ctx.key, { method: 'GET', - query: { project_id: input.project_id }, }); - await logEventFromContext( - ctx, - 'bannerbear.account.get', - { ...input }, - 'completed', - ); + await logEventFromContext(ctx, 'bannerbear.account.get', {}, 'completed'); return response; }; -export const getAuth: BannerbearEndpoints['getAuth'] = async (ctx, input) => { +export const getAuth: BannerbearEndpoints['getAuth'] = async (ctx, _input) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['getAuth'] >('/v5/account', ctx.key, { method: 'GET', - query: { project_id: input.project_id }, }); - await logEventFromContext( - ctx, - 'bannerbear.auth.get', - { ...input }, - 'completed', - ); + await logEventFromContext(ctx, 'bannerbear.auth.get', {}, 'completed'); return response; }; diff --git a/packages/bannerbear/endpoints/animation-templates.ts b/packages/bannerbear/endpoints/animation-templates.ts new file mode 100644 index 000000000..ae47d127a --- /dev/null +++ b/packages/bannerbear/endpoints/animation-templates.ts @@ -0,0 +1,60 @@ +import { logEventFromContext } from 'corsair/core'; +import type { BannerbearEndpoints } from '..'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; +import type { BannerbearEndpointOutputs } from './types'; + +export const list: BannerbearEndpoints['listAnimationTemplates'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['listAnimationTemplates'] + >('/v5/animation_templates', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animation_templates.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getAnimationTemplate'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAnimationTemplate'] + >(`/v5/animation_templates/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); + await logEventFromContext( + ctx, + 'bannerbear.animation_templates.get', + { ...input }, + 'completed', + ); + return response; +}; + +export const create: BannerbearEndpoints['createAnimationTemplate'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createAnimationTemplate'] + >('/v5/animation_templates', ctx.key, { + method: 'POST', + body: { ...input }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animation_templates.create', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/animations.ts b/packages/bannerbear/endpoints/animations.ts index 4c9ab6a4a..0cac5a606 100644 --- a/packages/bannerbear/endpoints/animations.ts +++ b/packages/bannerbear/endpoints/animations.ts @@ -1,44 +1,60 @@ import { logEventFromContext } from 'corsair/core'; import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; import type { BannerbearEndpointOutputs } from './types'; -export const list: BannerbearEndpoints['listAnimatedGifs'] = async ( +export const list: BannerbearEndpoints['listAnimations'] = async ( ctx, input, ) => { const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listAnimatedGifs'] - >('/v5/animated_gifs', ctx.key, { + BannerbearEndpointOutputs['listAnimations'] + >('/v5/animations', ctx.key, { + method: 'GET', + query: { page: input.page }, + }); + await logEventFromContext( + ctx, + 'bannerbear.animations.list', + { ...input }, + 'completed', + ); + return response; +}; + +export const get: BannerbearEndpoints['getAnimation'] = async (ctx, input) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['getAnimation'] + >(`/v5/animations/${encodeBannerbearUid(input.uid)}`, ctx.key, { method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, }); await logEventFromContext( ctx, - 'bannerbear.animated_gifs.list', + 'bannerbear.animations.get', { ...input }, 'completed', ); return response; }; -export const get: BannerbearEndpoints['getAnimatedGif'] = async ( +export const create: BannerbearEndpoints['createAnimation'] = async ( ctx, input, ) => { const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['getAnimatedGif'] - >(`/v5/animated_gifs/${input.uid}`, ctx.key, { - method: 'GET', - query: { project_id: input.project_id }, + BannerbearEndpointOutputs['createAnimation'] + >('/v5/animations', ctx.key, { + method: 'POST', + body: { + template: input.template, + modifications: input.modifications, + formats: input.formats, + metadata: input.metadata, + }, }); await logEventFromContext( ctx, - 'bannerbear.animated_gifs.get', + 'bannerbear.animations.create', { ...input }, 'completed', ); diff --git a/packages/bannerbear/endpoints/collections.ts b/packages/bannerbear/endpoints/collections.ts deleted file mode 100644 index 926c0b0e1..000000000 --- a/packages/bannerbear/endpoints/collections.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; -import type { BannerbearEndpointOutputs } from './types'; - -export const list: BannerbearEndpoints['listCollections'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listCollections'] - >('/v5/collections', ctx.key, { - method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, - }); - await logEventFromContext( - ctx, - 'bannerbear.collections.list', - { ...input }, - 'completed', - ); - return response; -}; diff --git a/packages/bannerbear/endpoints/images.ts b/packages/bannerbear/endpoints/images.ts index 9539bc00c..592f53989 100644 --- a/packages/bannerbear/endpoints/images.ts +++ b/packages/bannerbear/endpoints/images.ts @@ -1,6 +1,6 @@ import { logEventFromContext } from 'corsair/core'; import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; import type { BannerbearEndpointOutputs } from './types'; export const list: BannerbearEndpoints['listImages'] = async (ctx, input) => { @@ -8,11 +8,7 @@ export const list: BannerbearEndpoints['listImages'] = async (ctx, input) => { BannerbearEndpointOutputs['listImages'] >('/v5/images', ctx.key, { method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, + query: { page: input.page }, }); await logEventFromContext( ctx, @@ -26,9 +22,8 @@ export const list: BannerbearEndpoints['listImages'] = async (ctx, input) => { export const get: BannerbearEndpoints['getImage'] = async (ctx, input) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['getImage'] - >(`/v5/images/${input.uid}`, ctx.key, { + >(`/v5/images/${encodeBannerbearUid(input.uid)}`, ctx.key, { method: 'GET', - query: { project_id: input.project_id }, }); await logEventFromContext( ctx, @@ -38,3 +33,26 @@ export const get: BannerbearEndpoints['getImage'] = async (ctx, input) => { ); return response; }; + +export const create: BannerbearEndpoints['createImage'] = async ( + ctx, + input, +) => { + const response = await makeBannerbearRequest< + BannerbearEndpointOutputs['createImage'] + >('/v5/images', ctx.key, { + method: 'POST', + body: { + template: input.template, + modifications: input.modifications, + metadata: input.metadata, + }, + }); + await logEventFromContext( + ctx, + 'bannerbear.images.create', + { ...input }, + 'completed', + ); + return response; +}; diff --git a/packages/bannerbear/endpoints/index.ts b/packages/bannerbear/endpoints/index.ts index 4ba6914de..e26ca8798 100644 --- a/packages/bannerbear/endpoints/index.ts +++ b/packages/bannerbear/endpoints/index.ts @@ -1,14 +1,10 @@ import { getAccountInfo, getAuth } from './account'; +import * as AnimationTemplates from './animation-templates'; import * as Animations from './animations'; -import * as Collections from './collections'; import * as Images from './images'; -import { getFonts, joinPdfs, listEffects } from './misc'; -import * as Projects from './projects'; -import * as Screenshots from './screenshots'; -import { createSignedBase, getSignedBases } from './signed-urls'; -import * as TemplateSets from './template-sets'; +import { joinPdfs } from './misc'; +import * as InstantUrlHandlers from './signed-urls'; import * as Templates from './templates'; -import { createVideoTemplate, listVideos, listVideoTemplates } from './videos'; import * as WebhooksApi from './webhooks-api'; import { createWorkflowRun, @@ -23,31 +19,19 @@ export const Account = { getAuth, }; -export { Projects }; export { Templates }; -export { TemplateSets }; export { Images }; - -export const Videos = { - listVideos, - listVideoTemplates, - createVideoTemplate, -}; - export { Animations }; -export { Collections }; -export { Screenshots }; +export { AnimationTemplates }; -export const SignedUrls = { - getSignedBases, - createSignedBase, +export const InstantUrls = { + list: InstantUrlHandlers.list, + create: InstantUrlHandlers.create, }; export { WebhooksApi }; export const Misc = { - getFonts, - listEffects, joinPdfs, }; diff --git a/packages/bannerbear/endpoints/misc.ts b/packages/bannerbear/endpoints/misc.ts index e4af8c7cf..2d4531631 100644 --- a/packages/bannerbear/endpoints/misc.ts +++ b/packages/bannerbear/endpoints/misc.ts @@ -3,43 +3,19 @@ import type { BannerbearEndpoints } from '..'; import { makeBannerbearRequest } from '../client'; import type { BannerbearEndpointOutputs } from './types'; -export const getFonts: BannerbearEndpoints['getFonts'] = async ( - ctx, - _input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['getFonts'] - >('/v5/fonts', ctx.key, { method: 'GET' }); - await logEventFromContext(ctx, 'bannerbear.fonts.get', {}, 'completed'); - return response; -}; - -export const listEffects: BannerbearEndpoints['listEffects'] = async ( - ctx, - _input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listEffects'] - >('/v5/effects', ctx.key, { method: 'GET' }); - await logEventFromContext(ctx, 'bannerbear.effects.list', {}, 'completed'); - return response; -}; - export const joinPdfs: BannerbearEndpoints['joinPdfs'] = async (ctx, input) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['joinPdfs'] - >('/v5/pdfs', ctx.key, { + >('/v5/tools/create_pdf', ctx.key, { method: 'POST', body: { - pdf_urls: input.pdf_urls, - project_id: input.project_id, - webhook_url: input.webhook_url, + urls: input.urls, metadata: input.metadata, }, }); await logEventFromContext( ctx, - 'bannerbear.pdfs.join', + 'bannerbear.tools.create_pdf', { ...input }, 'completed', ); diff --git a/packages/bannerbear/endpoints/projects.ts b/packages/bannerbear/endpoints/projects.ts deleted file mode 100644 index c8fac4849..000000000 --- a/packages/bannerbear/endpoints/projects.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; -import type { BannerbearEndpointOutputs } from './types'; - -export const list: BannerbearEndpoints['listProjects'] = async (ctx, input) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listProjects'] - >('/v5/projects', ctx.key, { method: 'GET', query: { page: input.page } }); - await logEventFromContext( - ctx, - 'bannerbear.projects.list', - { ...input }, - 'completed', - ); - return response; -}; - -export const get: BannerbearEndpoints['getProject'] = async (ctx, input) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['getProject'] - >(`/v5/projects/${input.uid}`, ctx.key, { method: 'GET' }); - await logEventFromContext( - ctx, - 'bannerbear.projects.get', - { ...input }, - 'completed', - ); - return response; -}; - -export const create: BannerbearEndpoints['createProject'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['createProject'] - >('/v5/projects', ctx.key, { method: 'POST', body: { name: input.name } }); - await logEventFromContext( - ctx, - 'bannerbear.projects.create', - { ...input }, - 'completed', - ); - return response; -}; - -export const hydrate: BannerbearEndpoints['hydrateProject'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['hydrateProject'] - >(`/v5/projects/${input.uid}/hydrate`, ctx.key, { - method: 'POST', - body: { source_project: input.source_project }, - }); - await logEventFromContext( - ctx, - 'bannerbear.projects.hydrate', - { ...input }, - 'completed', - ); - return response; -}; diff --git a/packages/bannerbear/endpoints/screenshots.ts b/packages/bannerbear/endpoints/screenshots.ts deleted file mode 100644 index 0607ee17a..000000000 --- a/packages/bannerbear/endpoints/screenshots.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; -import type { BannerbearEndpointOutputs } from './types'; - -export const list: BannerbearEndpoints['listScreenshots'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listScreenshots'] - >('/v5/screenshots', ctx.key, { - method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, - }); - await logEventFromContext( - ctx, - 'bannerbear.screenshots.list', - { ...input }, - 'completed', - ); - return response; -}; - -export const get: BannerbearEndpoints['getScreenshot'] = async (ctx, input) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['getScreenshot'] - >(`/v5/screenshots/${input.uid}`, ctx.key, { - method: 'GET', - query: { project_id: input.project_id }, - }); - await logEventFromContext( - ctx, - 'bannerbear.screenshots.get', - { ...input }, - 'completed', - ); - return response; -}; diff --git a/packages/bannerbear/endpoints/signed-urls.ts b/packages/bannerbear/endpoints/signed-urls.ts index abef57b3e..c426c67c1 100644 --- a/packages/bannerbear/endpoints/signed-urls.ts +++ b/packages/bannerbear/endpoints/signed-urls.ts @@ -3,38 +3,38 @@ import type { BannerbearEndpoints } from '..'; import { makeBannerbearRequest } from '../client'; import type { BannerbearEndpointOutputs } from './types'; -export const getSignedBases: BannerbearEndpoints['getSignedBases'] = async ( +export const list: BannerbearEndpoints['listInstantUrls'] = async ( ctx, input, ) => { const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['getSignedBases'] - >(`/v5/templates/${input.uid}/signed_bases`, ctx.key, { + BannerbearEndpointOutputs['listInstantUrls'] + >('/v5/instant_urls', ctx.key, { method: 'GET', - query: { project_id: input.project_id }, + query: { page: input.page }, }); await logEventFromContext( ctx, - 'bannerbear.signed_bases.get', + 'bannerbear.instant_urls.list', { ...input }, 'completed', ); return response; }; -export const createSignedBase: BannerbearEndpoints['createSignedBase'] = async ( +export const create: BannerbearEndpoints['createInstantUrl'] = async ( ctx, input, ) => { const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['createSignedBase'] - >(`/v5/templates/${input.uid}/signed_bases`, ctx.key, { + BannerbearEndpointOutputs['createInstantUrl'] + >('/v5/instant_urls', ctx.key, { method: 'POST', - body: { project_id: input.project_id }, + body: { ...input }, }); await logEventFromContext( ctx, - 'bannerbear.signed_bases.create', + 'bannerbear.instant_urls.create', { ...input }, 'completed', ); diff --git a/packages/bannerbear/endpoints/template-sets.ts b/packages/bannerbear/endpoints/template-sets.ts deleted file mode 100644 index dcf695d10..000000000 --- a/packages/bannerbear/endpoints/template-sets.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; -import type { BannerbearEndpointOutputs } from './types'; - -export const list: BannerbearEndpoints['listTemplateSets'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listTemplateSets'] - >('/v5/template_sets', ctx.key, { - method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, - }); - await logEventFromContext( - ctx, - 'bannerbear.template_sets.list', - { ...input }, - 'completed', - ); - return response; -}; - -export const get: BannerbearEndpoints['getTemplateSet'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['getTemplateSet'] - >(`/v5/template_sets/${input.uid}`, ctx.key, { - method: 'GET', - query: { project_id: input.project_id }, - }); - await logEventFromContext( - ctx, - 'bannerbear.template_sets.get', - { ...input }, - 'completed', - ); - return response; -}; - -export const create: BannerbearEndpoints['createTemplateSet'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['createTemplateSet'] - >('/v5/template_sets', ctx.key, { - method: 'POST', - body: { - name: input.name, - templates: input.templates, - project_id: input.project_id, - }, - }); - await logEventFromContext( - ctx, - 'bannerbear.template_sets.create', - { ...input }, - 'completed', - ); - return response; -}; - -export const update: BannerbearEndpoints['updateTemplateSet'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['updateTemplateSet'] - >(`/v5/template_sets/${input.uid}`, ctx.key, { - method: 'PATCH', - body: { templates: input.templates, project_id: input.project_id }, - }); - await logEventFromContext( - ctx, - 'bannerbear.template_sets.update', - { ...input }, - 'completed', - ); - return response; -}; diff --git a/packages/bannerbear/endpoints/templates.ts b/packages/bannerbear/endpoints/templates.ts index b19d3b9e6..67bf1c3cf 100644 --- a/packages/bannerbear/endpoints/templates.ts +++ b/packages/bannerbear/endpoints/templates.ts @@ -1,6 +1,6 @@ import { logEventFromContext } from 'corsair/core'; import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; import type { BannerbearEndpointOutputs } from './types'; export const list: BannerbearEndpoints['listTemplates'] = async ( @@ -9,13 +9,9 @@ export const list: BannerbearEndpoints['listTemplates'] = async ( ) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['listTemplates'] - >('/v5/templates', ctx.key, { + >('/v5/image_templates', ctx.key, { method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, + query: { page: input.page }, }); await logEventFromContext( ctx, @@ -29,9 +25,8 @@ export const list: BannerbearEndpoints['listTemplates'] = async ( export const get: BannerbearEndpoints['getTemplate'] = async (ctx, input) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['getTemplate'] - >(`/v5/templates/${input.uid}`, ctx.key, { + >(`/v5/image_templates/${encodeBannerbearUid(input.uid)}`, ctx.key, { method: 'GET', - query: { project_id: input.project_id }, }); await logEventFromContext( ctx, @@ -46,12 +41,11 @@ export const create: BannerbearEndpoints['createTemplate'] = async ( ctx, input, ) => { - const { project_id, ...body } = input; const response = await makeBannerbearRequest< BannerbearEndpointOutputs['createTemplate'] - >('/v5/templates', ctx.key, { + >('/v5/image_templates', ctx.key, { method: 'POST', - body: { ...body, project_id }, + body: { ...input }, }); await logEventFromContext( ctx, @@ -66,10 +60,11 @@ export const deleteTemplate: BannerbearEndpoints['deleteTemplate'] = async ( ctx, input, ) => { - await makeBannerbearRequest(`/v5/templates/${input.uid}`, ctx.key, { - method: 'DELETE', - query: { project_id: input.project_id }, - }); + await makeBannerbearRequest( + `/v5/image_templates/${encodeBannerbearUid(input.uid)}`, + ctx.key, + { method: 'DELETE' }, + ); await logEventFromContext( ctx, 'bannerbear.templates.delete', @@ -85,13 +80,11 @@ export const importTemplate: BannerbearEndpoints['importTemplate'] = async ( ) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['importTemplate'] - >('/v5/templates/import', ctx.key, { - method: 'POST', - body: { - publication_id: input.publication_id, - project_id: input.project_id, - }, - }); + >( + `/v5/publications/${encodeBannerbearUid(input.publication_id)}/install`, + ctx.key, + { method: 'POST', body: {} }, + ); await logEventFromContext( ctx, 'bannerbear.templates.import', diff --git a/packages/bannerbear/endpoints/types.ts b/packages/bannerbear/endpoints/types.ts index 6a28c8efe..b2e7c0e58 100644 --- a/packages/bannerbear/endpoints/types.ts +++ b/packages/bannerbear/endpoints/types.ts @@ -1,85 +1,40 @@ import { z } from 'zod'; import { BannerbearAccount, - BannerbearAnimatedGif, - BannerbearCollection, - BannerbearEffect, - BannerbearFont, + BannerbearAnimation, + BannerbearAnimationTemplate, BannerbearImage, + BannerbearInstantUrl, BannerbearPdfJoin, - BannerbearProject, - BannerbearScreenshot, - BannerbearSignedBase, BannerbearTemplate, - BannerbearTemplateSet, - BannerbearVideo, - BannerbearVideoTemplate, BannerbearWebhookObj, BannerbearWorkflow, BannerbearWorkflowRun, } from '../schema/database'; -// ─── Pagination helper ─────────────────────────────────────── -const PaginationInput = z.object({ +const PageInput = z.object({ page: z.number().optional(), - limit: z.number().optional(), - project_id: z.string().optional(), }); -// ─── Account ───────────────────────────────────────────────── -const GetAccountInfoInputSchema = z.object({ - project_id: z.string().optional(), -}); +const GetAccountInfoInputSchema = z.object({}); export type GetAccountInfoInput = z.infer; const GetAccountInfoResponseSchema = BannerbearAccount; export type GetAccountInfoResponse = z.infer< typeof GetAccountInfoResponseSchema >; -const GetAuthInputSchema = z.object({ - project_id: z.string().optional(), -}); +const GetAuthInputSchema = z.object({}); export type GetAuthInput = z.infer; const GetAuthResponseSchema = BannerbearAccount; export type GetAuthResponse = z.infer; -// ─── Projects ──────────────────────────────────────────────── -const ListProjectsInputSchema = PaginationInput.pick({ page: true }); -export type ListProjectsInput = z.infer; -const ListProjectsResponseSchema = z.array(BannerbearProject); -export type ListProjectsResponse = z.infer; - -const GetProjectInputSchema = z.object({ uid: z.string() }); -export type GetProjectInput = z.infer; -const GetProjectResponseSchema = BannerbearProject; -export type GetProjectResponse = z.infer; - -const CreateProjectInputSchema = z.object({ - name: z.string(), -}); -export type CreateProjectInput = z.infer; -const CreateProjectResponseSchema = BannerbearProject; -export type CreateProjectResponse = z.infer; - -const HydrateProjectInputSchema = z.object({ - uid: z.string(), - source_project: z.string(), -}); -export type HydrateProjectInput = z.infer; -const HydrateProjectResponseSchema = BannerbearProject; -export type HydrateProjectResponse = z.infer< - typeof HydrateProjectResponseSchema ->; - -// ─── Templates ─────────────────────────────────────────────── -const ListTemplatesInputSchema = PaginationInput; +const ListTemplatesInputSchema = PageInput; export type ListTemplatesInput = z.infer; const ListTemplatesResponseSchema = z.array(BannerbearTemplate); export type ListTemplatesResponse = z.infer; const GetTemplateInputSchema = z.object({ uid: z.string(), - project_id: z.string().optional(), }); export type GetTemplateInput = z.infer; const GetTemplateResponseSchema = BannerbearTemplate; @@ -87,9 +42,11 @@ export type GetTemplateResponse = z.infer; const CreateTemplateInputSchema = z.object({ name: z.string(), + description: z.string().optional(), width: z.number().optional(), height: z.number().optional(), - project_id: z.string().optional(), + tags: z.array(z.string()).optional(), + config: z.record(z.string(), z.unknown()).optional(), }); export type CreateTemplateInput = z.infer; const CreateTemplateResponseSchema = BannerbearTemplate; @@ -99,7 +56,6 @@ export type CreateTemplateResponse = z.infer< const DeleteTemplateInputSchema = z.object({ uid: z.string(), - project_id: z.string().optional(), }); export type DeleteTemplateInput = z.infer; const DeleteTemplateResponseSchema = z.object({ success: z.boolean() }); @@ -109,7 +65,6 @@ export type DeleteTemplateResponse = z.infer< const ImportTemplateInputSchema = z.object({ publication_id: z.string(), - project_id: z.string().optional(), }); export type ImportTemplateInput = z.infer; const ImportTemplateResponseSchema = BannerbearTemplate; @@ -117,173 +72,135 @@ export type ImportTemplateResponse = z.infer< typeof ImportTemplateResponseSchema >; -// ─── Template Sets ─────────────────────────────────────────── -const ListTemplateSetsInputSchema = PaginationInput; -export type ListTemplateSetsInput = z.infer; -const ListTemplateSetsResponseSchema = z.array(BannerbearTemplateSet); -export type ListTemplateSetsResponse = z.infer< - typeof ListTemplateSetsResponseSchema ->; - -const GetTemplateSetInputSchema = z.object({ - uid: z.string(), - project_id: z.string().optional(), -}); -export type GetTemplateSetInput = z.infer; -const GetTemplateSetResponseSchema = BannerbearTemplateSet; -export type GetTemplateSetResponse = z.infer< - typeof GetTemplateSetResponseSchema ->; - -const CreateTemplateSetInputSchema = z.object({ - name: z.string(), - templates: z.array(z.string()), - project_id: z.string().optional(), -}); -export type CreateTemplateSetInput = z.infer< - typeof CreateTemplateSetInputSchema ->; -const CreateTemplateSetResponseSchema = BannerbearTemplateSet; -export type CreateTemplateSetResponse = z.infer< - typeof CreateTemplateSetResponseSchema ->; - -const UpdateTemplateSetInputSchema = z.object({ - uid: z.string(), - templates: z.array(z.string()), - project_id: z.string().optional(), -}); -export type UpdateTemplateSetInput = z.infer< - typeof UpdateTemplateSetInputSchema ->; -const UpdateTemplateSetResponseSchema = BannerbearTemplateSet; -export type UpdateTemplateSetResponse = z.infer< - typeof UpdateTemplateSetResponseSchema ->; - -// ─── Images ────────────────────────────────────────────────── -const ListImagesInputSchema = PaginationInput; +const ListImagesInputSchema = PageInput; export type ListImagesInput = z.infer; const ListImagesResponseSchema = z.array(BannerbearImage); export type ListImagesResponse = z.infer; const GetImageInputSchema = z.object({ uid: z.string(), - project_id: z.string().optional(), }); export type GetImageInput = z.infer; const GetImageResponseSchema = BannerbearImage; export type GetImageResponse = z.infer; -// ─── Videos ────────────────────────────────────────────────── -const ListVideosInputSchema = PaginationInput; -export type ListVideosInput = z.infer; -const ListVideosResponseSchema = z.array(BannerbearVideo); -export type ListVideosResponse = z.infer; - -const ListVideoTemplatesInputSchema = PaginationInput; -export type ListVideoTemplatesInput = z.infer< - typeof ListVideoTemplatesInputSchema ->; -const ListVideoTemplatesResponseSchema = z.array(BannerbearVideoTemplate); -export type ListVideoTemplatesResponse = z.infer< - typeof ListVideoTemplatesResponseSchema ->; - -const CreateVideoTemplateInputSchema = z.object({ - name: z.string(), +const CreateImageInputSchema = z.object({ template: z.string(), - project_id: z.string().optional(), - input_media_url: z.string().optional(), - frames: z.array(z.record(z.string(), z.unknown())).optional(), - soundtrack_url: z.string().optional(), - transition: z.string().optional(), - transcription: z.record(z.string(), z.unknown()).optional(), + modifications: z.record(z.string(), z.unknown()), + metadata: z.string().optional(), }); -export type CreateVideoTemplateInput = z.infer< - typeof CreateVideoTemplateInputSchema ->; -const CreateVideoTemplateResponseSchema = BannerbearVideoTemplate; -export type CreateVideoTemplateResponse = z.infer< - typeof CreateVideoTemplateResponseSchema ->; - -// ─── Animated GIFs ─────────────────────────────────────────── -const ListAnimatedGifsInputSchema = PaginationInput; -export type ListAnimatedGifsInput = z.infer; -const ListAnimatedGifsResponseSchema = z.array(BannerbearAnimatedGif); -export type ListAnimatedGifsResponse = z.infer< - typeof ListAnimatedGifsResponseSchema +export type CreateImageInput = z.infer; +const CreateImageResponseSchema = BannerbearImage; +export type CreateImageResponse = z.infer; + +const ListAnimationsInputSchema = PageInput; +export type ListAnimationsInput = z.infer; +const ListAnimationsResponseSchema = z.array(BannerbearAnimation); +export type ListAnimationsResponse = z.infer< + typeof ListAnimationsResponseSchema >; -const GetAnimatedGifInputSchema = z.object({ +const GetAnimationInputSchema = z.object({ uid: z.string(), - project_id: z.string().optional(), }); -export type GetAnimatedGifInput = z.infer; -const GetAnimatedGifResponseSchema = BannerbearAnimatedGif; -export type GetAnimatedGifResponse = z.infer< - typeof GetAnimatedGifResponseSchema ->; +export type GetAnimationInput = z.infer; +const GetAnimationResponseSchema = BannerbearAnimation; +export type GetAnimationResponse = z.infer; -// ─── Collections ───────────────────────────────────────────── -const ListCollectionsInputSchema = PaginationInput; -export type ListCollectionsInput = z.infer; -const ListCollectionsResponseSchema = z.array(BannerbearCollection); -export type ListCollectionsResponse = z.infer< - typeof ListCollectionsResponseSchema +const CreateAnimationInputSchema = z.object({ + template: z.string(), + modifications: z.record(z.string(), z.unknown()), + formats: z.array(z.enum(['mp4', 'mov'])).optional(), + metadata: z.string().optional(), +}); +export type CreateAnimationInput = z.infer; +const CreateAnimationResponseSchema = BannerbearAnimation; +export type CreateAnimationResponse = z.infer< + typeof CreateAnimationResponseSchema >; -// ─── Screenshots ───────────────────────────────────────────── -const ListScreenshotsInputSchema = PaginationInput; -export type ListScreenshotsInput = z.infer; -const ListScreenshotsResponseSchema = z.array(BannerbearScreenshot); -export type ListScreenshotsResponse = z.infer< - typeof ListScreenshotsResponseSchema +const ListAnimationTemplatesInputSchema = PageInput; +export type ListAnimationTemplatesInput = z.infer< + typeof ListAnimationTemplatesInputSchema +>; +const ListAnimationTemplatesResponseSchema = z.array( + BannerbearAnimationTemplate, +); +export type ListAnimationTemplatesResponse = z.infer< + typeof ListAnimationTemplatesResponseSchema >; -const GetScreenshotInputSchema = z.object({ +const GetAnimationTemplateInputSchema = z.object({ uid: z.string(), - project_id: z.string().optional(), }); -export type GetScreenshotInput = z.infer; -const GetScreenshotResponseSchema = BannerbearScreenshot; -export type GetScreenshotResponse = z.infer; +export type GetAnimationTemplateInput = z.infer< + typeof GetAnimationTemplateInputSchema +>; +const GetAnimationTemplateResponseSchema = BannerbearAnimationTemplate; +export type GetAnimationTemplateResponse = z.infer< + typeof GetAnimationTemplateResponseSchema +>; -// ─── Signed URLs ───────────────────────────────────────────── -const GetSignedBasesInputSchema = z.object({ - uid: z.string(), - project_id: z.string().optional(), +const CreateAnimationTemplateInputSchema = z.object({ + name: z.string(), + description: z.string().optional(), + tags: z.array(z.string()).optional(), + width: z.number().optional(), + height: z.number().optional(), + frame_rate: z.union([z.literal(24), z.literal(30), z.literal(60)]).optional(), }); -export type GetSignedBasesInput = z.infer; -const GetSignedBasesResponseSchema = z.array(BannerbearSignedBase); -export type GetSignedBasesResponse = z.infer< - typeof GetSignedBasesResponseSchema +export type CreateAnimationTemplateInput = z.infer< + typeof CreateAnimationTemplateInputSchema +>; +const CreateAnimationTemplateResponseSchema = BannerbearAnimationTemplate; +export type CreateAnimationTemplateResponse = z.infer< + typeof CreateAnimationTemplateResponseSchema >; -const CreateSignedBaseInputSchema = z.object({ - uid: z.string(), - project_id: z.string().optional(), +const ListInstantUrlsInputSchema = PageInput; +export type ListInstantUrlsInput = z.infer; +const ListInstantUrlsResponseSchema = z.array(BannerbearInstantUrl); +export type ListInstantUrlsResponse = z.infer< + typeof ListInstantUrlsResponseSchema +>; + +const CreateInstantUrlInputSchema = z.object({ + name: z.string(), + template: z.string(), + mode: z.enum(['encoded', 'named_params']).optional(), + security: z.enum(['signed', 'open']).optional(), + status: z.enum(['active', 'disabled']).optional(), + scale: z + .union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)]) + .optional(), + rate_limit: z.boolean().optional(), + template_version: z.number().nullable().optional(), + max_renders: z.number().nullable().optional(), + expires_at: z.string().nullable().optional(), }); -export type CreateSignedBaseInput = z.infer; -const CreateSignedBaseResponseSchema = BannerbearSignedBase; -export type CreateSignedBaseResponse = z.infer< - typeof CreateSignedBaseResponseSchema +export type CreateInstantUrlInput = z.infer; +const CreateInstantUrlResponseSchema = BannerbearInstantUrl; +export type CreateInstantUrlResponse = z.infer< + typeof CreateInstantUrlResponseSchema >; -// ─── Webhooks API ──────────────────────────────────────────── const GetWebhookInputSchema = z.object({ uid: z.string(), - project_id: z.string().optional(), }); export type GetWebhookInput = z.infer; const GetWebhookResponseSchema = BannerbearWebhookObj; export type GetWebhookResponse = z.infer; const CreateWebhookInputSchema = z.object({ + name: z.string(), url: z.string(), - event: z.string().optional(), - project_id: z.string().optional(), + resource: z + .enum(['image', 'batch', 'tool_job', 'workflow_run', 'animation']) + .optional(), + event: z.enum(['all_events', 'completed', 'failed']).optional(), + status: z.enum(['active', 'disabled']).optional(), + scope: z.enum(['all_templates', 'specific_templates']).optional(), + templates: z.array(z.string()).optional(), }); export type CreateWebhookInput = z.infer; const CreateWebhookResponseSchema = BannerbearWebhookObj; @@ -291,35 +208,20 @@ export type CreateWebhookResponse = z.infer; const DeleteWebhookInputSchema = z.object({ uid: z.string(), - project_id: z.string().optional(), }); export type DeleteWebhookInput = z.infer; const DeleteWebhookResponseSchema = z.object({ success: z.boolean() }); export type DeleteWebhookResponse = z.infer; -// ─── Misc ──────────────────────────────────────────────────── -const GetFontsInputSchema = z.object({}); -export type GetFontsInput = z.infer; -const GetFontsResponseSchema = z.array(BannerbearFont); -export type GetFontsResponse = z.infer; - -const ListEffectsInputSchema = z.object({}); -export type ListEffectsInput = z.infer; -const ListEffectsResponseSchema = z.array(BannerbearEffect); -export type ListEffectsResponse = z.infer; - const JoinPdfsInputSchema = z.object({ - pdf_urls: z.array(z.string()), - project_id: z.string().optional(), - webhook_url: z.string().optional(), + urls: z.array(z.string()), metadata: z.string().optional(), }); export type JoinPdfsInput = z.infer; const JoinPdfsResponseSchema = BannerbearPdfJoin; export type JoinPdfsResponse = z.infer; -// ─── Workflows ─────────────────────────────────────────────── -const ListWorkflowsInputSchema = PaginationInput.pick({ page: true }); +const ListWorkflowsInputSchema = PageInput; export type ListWorkflowsInput = z.infer; const ListWorkflowsResponseSchema = z.array(BannerbearWorkflow); export type ListWorkflowsResponse = z.infer; @@ -332,7 +234,6 @@ export type GetWorkflowResponse = z.infer; const CreateWorkflowRunInputSchema = z.object({ workflow: z.string(), inputs: z.record(z.string(), z.unknown()).optional(), - webhook_url: z.string().optional(), }); export type CreateWorkflowRunInput = z.infer< typeof CreateWorkflowRunInputSchema @@ -349,47 +250,35 @@ export type GetWorkflowRunResponse = z.infer< typeof GetWorkflowRunResponseSchema >; -const ListWorkflowRunsInputSchema = PaginationInput.pick({ page: true }); +const ListWorkflowRunsInputSchema = PageInput; export type ListWorkflowRunsInput = z.infer; const ListWorkflowRunsResponseSchema = z.array(BannerbearWorkflowRun); export type ListWorkflowRunsResponse = z.infer< typeof ListWorkflowRunsResponseSchema >; -// ─── Aggregate types ───────────────────────────────────────── export type BannerbearEndpointInputs = { getAccountInfo: GetAccountInfoInput; getAuth: GetAuthInput; - listProjects: ListProjectsInput; - getProject: GetProjectInput; - createProject: CreateProjectInput; - hydrateProject: HydrateProjectInput; listTemplates: ListTemplatesInput; getTemplate: GetTemplateInput; createTemplate: CreateTemplateInput; deleteTemplate: DeleteTemplateInput; importTemplate: ImportTemplateInput; - listTemplateSets: ListTemplateSetsInput; - getTemplateSet: GetTemplateSetInput; - createTemplateSet: CreateTemplateSetInput; - updateTemplateSet: UpdateTemplateSetInput; listImages: ListImagesInput; getImage: GetImageInput; - listVideos: ListVideosInput; - listVideoTemplates: ListVideoTemplatesInput; - createVideoTemplate: CreateVideoTemplateInput; - listAnimatedGifs: ListAnimatedGifsInput; - getAnimatedGif: GetAnimatedGifInput; - listCollections: ListCollectionsInput; - listScreenshots: ListScreenshotsInput; - getScreenshot: GetScreenshotInput; - getSignedBases: GetSignedBasesInput; - createSignedBase: CreateSignedBaseInput; + createImage: CreateImageInput; + listAnimations: ListAnimationsInput; + getAnimation: GetAnimationInput; + createAnimation: CreateAnimationInput; + listAnimationTemplates: ListAnimationTemplatesInput; + getAnimationTemplate: GetAnimationTemplateInput; + createAnimationTemplate: CreateAnimationTemplateInput; + listInstantUrls: ListInstantUrlsInput; + createInstantUrl: CreateInstantUrlInput; getWebhook: GetWebhookInput; createWebhook: CreateWebhookInput; deleteWebhook: DeleteWebhookInput; - getFonts: GetFontsInput; - listEffects: ListEffectsInput; joinPdfs: JoinPdfsInput; listWorkflows: ListWorkflowsInput; getWorkflow: GetWorkflowInput; @@ -401,36 +290,25 @@ export type BannerbearEndpointInputs = { export type BannerbearEndpointOutputs = { getAccountInfo: GetAccountInfoResponse; getAuth: GetAuthResponse; - listProjects: ListProjectsResponse; - getProject: GetProjectResponse; - createProject: CreateProjectResponse; - hydrateProject: HydrateProjectResponse; listTemplates: ListTemplatesResponse; getTemplate: GetTemplateResponse; createTemplate: CreateTemplateResponse; deleteTemplate: DeleteTemplateResponse; importTemplate: ImportTemplateResponse; - listTemplateSets: ListTemplateSetsResponse; - getTemplateSet: GetTemplateSetResponse; - createTemplateSet: CreateTemplateSetResponse; - updateTemplateSet: UpdateTemplateSetResponse; listImages: ListImagesResponse; getImage: GetImageResponse; - listVideos: ListVideosResponse; - listVideoTemplates: ListVideoTemplatesResponse; - createVideoTemplate: CreateVideoTemplateResponse; - listAnimatedGifs: ListAnimatedGifsResponse; - getAnimatedGif: GetAnimatedGifResponse; - listCollections: ListCollectionsResponse; - listScreenshots: ListScreenshotsResponse; - getScreenshot: GetScreenshotResponse; - getSignedBases: GetSignedBasesResponse; - createSignedBase: CreateSignedBaseResponse; + createImage: CreateImageResponse; + listAnimations: ListAnimationsResponse; + getAnimation: GetAnimationResponse; + createAnimation: CreateAnimationResponse; + listAnimationTemplates: ListAnimationTemplatesResponse; + getAnimationTemplate: GetAnimationTemplateResponse; + createAnimationTemplate: CreateAnimationTemplateResponse; + listInstantUrls: ListInstantUrlsResponse; + createInstantUrl: CreateInstantUrlResponse; getWebhook: GetWebhookResponse; createWebhook: CreateWebhookResponse; deleteWebhook: DeleteWebhookResponse; - getFonts: GetFontsResponse; - listEffects: ListEffectsResponse; joinPdfs: JoinPdfsResponse; listWorkflows: ListWorkflowsResponse; getWorkflow: GetWorkflowResponse; @@ -442,36 +320,25 @@ export type BannerbearEndpointOutputs = { export const BannerbearEndpointInputSchemas = { getAccountInfo: GetAccountInfoInputSchema, getAuth: GetAuthInputSchema, - listProjects: ListProjectsInputSchema, - getProject: GetProjectInputSchema, - createProject: CreateProjectInputSchema, - hydrateProject: HydrateProjectInputSchema, listTemplates: ListTemplatesInputSchema, getTemplate: GetTemplateInputSchema, createTemplate: CreateTemplateInputSchema, deleteTemplate: DeleteTemplateInputSchema, importTemplate: ImportTemplateInputSchema, - listTemplateSets: ListTemplateSetsInputSchema, - getTemplateSet: GetTemplateSetInputSchema, - createTemplateSet: CreateTemplateSetInputSchema, - updateTemplateSet: UpdateTemplateSetInputSchema, listImages: ListImagesInputSchema, getImage: GetImageInputSchema, - listVideos: ListVideosInputSchema, - listVideoTemplates: ListVideoTemplatesInputSchema, - createVideoTemplate: CreateVideoTemplateInputSchema, - listAnimatedGifs: ListAnimatedGifsInputSchema, - getAnimatedGif: GetAnimatedGifInputSchema, - listCollections: ListCollectionsInputSchema, - listScreenshots: ListScreenshotsInputSchema, - getScreenshot: GetScreenshotInputSchema, - getSignedBases: GetSignedBasesInputSchema, - createSignedBase: CreateSignedBaseInputSchema, + createImage: CreateImageInputSchema, + listAnimations: ListAnimationsInputSchema, + getAnimation: GetAnimationInputSchema, + createAnimation: CreateAnimationInputSchema, + listAnimationTemplates: ListAnimationTemplatesInputSchema, + getAnimationTemplate: GetAnimationTemplateInputSchema, + createAnimationTemplate: CreateAnimationTemplateInputSchema, + listInstantUrls: ListInstantUrlsInputSchema, + createInstantUrl: CreateInstantUrlInputSchema, getWebhook: GetWebhookInputSchema, createWebhook: CreateWebhookInputSchema, deleteWebhook: DeleteWebhookInputSchema, - getFonts: GetFontsInputSchema, - listEffects: ListEffectsInputSchema, joinPdfs: JoinPdfsInputSchema, listWorkflows: ListWorkflowsInputSchema, getWorkflow: GetWorkflowInputSchema, @@ -483,36 +350,25 @@ export const BannerbearEndpointInputSchemas = { export const BannerbearEndpointOutputSchemas = { getAccountInfo: GetAccountInfoResponseSchema, getAuth: GetAuthResponseSchema, - listProjects: ListProjectsResponseSchema, - getProject: GetProjectResponseSchema, - createProject: CreateProjectResponseSchema, - hydrateProject: HydrateProjectResponseSchema, listTemplates: ListTemplatesResponseSchema, getTemplate: GetTemplateResponseSchema, createTemplate: CreateTemplateResponseSchema, deleteTemplate: DeleteTemplateResponseSchema, importTemplate: ImportTemplateResponseSchema, - listTemplateSets: ListTemplateSetsResponseSchema, - getTemplateSet: GetTemplateSetResponseSchema, - createTemplateSet: CreateTemplateSetResponseSchema, - updateTemplateSet: UpdateTemplateSetResponseSchema, listImages: ListImagesResponseSchema, getImage: GetImageResponseSchema, - listVideos: ListVideosResponseSchema, - listVideoTemplates: ListVideoTemplatesResponseSchema, - createVideoTemplate: CreateVideoTemplateResponseSchema, - listAnimatedGifs: ListAnimatedGifsResponseSchema, - getAnimatedGif: GetAnimatedGifResponseSchema, - listCollections: ListCollectionsResponseSchema, - listScreenshots: ListScreenshotsResponseSchema, - getScreenshot: GetScreenshotResponseSchema, - getSignedBases: GetSignedBasesResponseSchema, - createSignedBase: CreateSignedBaseResponseSchema, + createImage: CreateImageResponseSchema, + listAnimations: ListAnimationsResponseSchema, + getAnimation: GetAnimationResponseSchema, + createAnimation: CreateAnimationResponseSchema, + listAnimationTemplates: ListAnimationTemplatesResponseSchema, + getAnimationTemplate: GetAnimationTemplateResponseSchema, + createAnimationTemplate: CreateAnimationTemplateResponseSchema, + listInstantUrls: ListInstantUrlsResponseSchema, + createInstantUrl: CreateInstantUrlResponseSchema, getWebhook: GetWebhookResponseSchema, createWebhook: CreateWebhookResponseSchema, deleteWebhook: DeleteWebhookResponseSchema, - getFonts: GetFontsResponseSchema, - listEffects: ListEffectsResponseSchema, joinPdfs: JoinPdfsResponseSchema, listWorkflows: ListWorkflowsResponseSchema, getWorkflow: GetWorkflowResponseSchema, diff --git a/packages/bannerbear/endpoints/videos.ts b/packages/bannerbear/endpoints/videos.ts deleted file mode 100644 index 05394b8f3..000000000 --- a/packages/bannerbear/endpoints/videos.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { logEventFromContext } from 'corsair/core'; -import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; -import type { BannerbearEndpointOutputs } from './types'; - -export const listVideos: BannerbearEndpoints['listVideos'] = async ( - ctx, - input, -) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listVideos'] - >('/v5/videos', ctx.key, { - method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, - }); - await logEventFromContext( - ctx, - 'bannerbear.videos.list', - { ...input }, - 'completed', - ); - return response; -}; - -export const listVideoTemplates: BannerbearEndpoints['listVideoTemplates'] = - async (ctx, input) => { - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['listVideoTemplates'] - >('/v5/video_templates', ctx.key, { - method: 'GET', - query: { - page: input.page, - limit: input.limit, - project_id: input.project_id, - }, - }); - await logEventFromContext( - ctx, - 'bannerbear.video_templates.list', - { ...input }, - 'completed', - ); - return response; - }; - -export const createVideoTemplate: BannerbearEndpoints['createVideoTemplate'] = - async (ctx, input) => { - const { project_id, ...body } = input; - const response = await makeBannerbearRequest< - BannerbearEndpointOutputs['createVideoTemplate'] - >('/v5/video_templates', ctx.key, { - method: 'POST', - body: { ...body, project_id }, - }); - await logEventFromContext( - ctx, - 'bannerbear.video_templates.create', - { ...input }, - 'completed', - ); - return response; - }; diff --git a/packages/bannerbear/endpoints/webhooks-api.ts b/packages/bannerbear/endpoints/webhooks-api.ts index 28c2648a4..813379441 100644 --- a/packages/bannerbear/endpoints/webhooks-api.ts +++ b/packages/bannerbear/endpoints/webhooks-api.ts @@ -1,14 +1,13 @@ import { logEventFromContext } from 'corsair/core'; import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; import type { BannerbearEndpointOutputs } from './types'; export const get: BannerbearEndpoints['getWebhook'] = async (ctx, input) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['getWebhook'] - >(`/v5/webhooks/${input.uid}`, ctx.key, { + >(`/v5/webhooks/${encodeBannerbearUid(input.uid)}`, ctx.key, { method: 'GET', - query: { project_id: input.project_id }, }); await logEventFromContext( ctx, @@ -27,7 +26,7 @@ export const create: BannerbearEndpoints['createWebhook'] = async ( BannerbearEndpointOutputs['createWebhook'] >('/v5/webhooks', ctx.key, { method: 'POST', - body: { url: input.url, event: input.event, project_id: input.project_id }, + body: { ...input }, }); await logEventFromContext( ctx, @@ -42,10 +41,11 @@ export const deleteWebhook: BannerbearEndpoints['deleteWebhook'] = async ( ctx, input, ) => { - await makeBannerbearRequest(`/v5/webhooks/${input.uid}`, ctx.key, { - method: 'DELETE', - query: { project_id: input.project_id }, - }); + await makeBannerbearRequest( + `/v5/webhooks/${encodeBannerbearUid(input.uid)}`, + ctx.key, + { method: 'DELETE' }, + ); await logEventFromContext( ctx, 'bannerbear.webhooks.delete', diff --git a/packages/bannerbear/endpoints/workflows.ts b/packages/bannerbear/endpoints/workflows.ts index 405e3f1ad..dd7e055f2 100644 --- a/packages/bannerbear/endpoints/workflows.ts +++ b/packages/bannerbear/endpoints/workflows.ts @@ -1,6 +1,6 @@ import { logEventFromContext } from 'corsair/core'; import type { BannerbearEndpoints } from '..'; -import { makeBannerbearRequest } from '../client'; +import { encodeBannerbearUid, makeBannerbearRequest } from '../client'; import type { BannerbearEndpointOutputs } from './types'; export const listWorkflows: BannerbearEndpoints['listWorkflows'] = async ( @@ -25,7 +25,9 @@ export const getWorkflow: BannerbearEndpoints['getWorkflow'] = async ( ) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['getWorkflow'] - >(`/v5/workflows/${input.uid}`, ctx.key, { method: 'GET' }); + >(`/v5/workflows/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); await logEventFromContext( ctx, 'bannerbear.workflows.get', @@ -44,7 +46,6 @@ export const createWorkflowRun: BannerbearEndpoints['createWorkflowRun'] = body: { workflow: input.workflow, inputs: input.inputs, - webhook_url: input.webhook_url, }, }); await logEventFromContext( @@ -62,7 +63,9 @@ export const getWorkflowRun: BannerbearEndpoints['getWorkflowRun'] = async ( ) => { const response = await makeBannerbearRequest< BannerbearEndpointOutputs['getWorkflowRun'] - >(`/v5/workflow_runs/${input.uid}`, ctx.key, { method: 'GET' }); + >(`/v5/workflow_runs/${encodeBannerbearUid(input.uid)}`, ctx.key, { + method: 'GET', + }); await logEventFromContext( ctx, 'bannerbear.workflow_runs.get', diff --git a/packages/bannerbear/index.ts b/packages/bannerbear/index.ts index 7047bebf4..1ca2f2607 100644 --- a/packages/bannerbear/index.ts +++ b/packages/bannerbear/index.ts @@ -15,18 +15,15 @@ import type { RequiredPluginEndpointSchemas, RequiredPluginWebhookSchemas, } from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; import { Account, Animations, - Collections, + AnimationTemplates, Images, + InstantUrls, Misc, - Projects, - Screenshots, - SignedUrls, - TemplateSets, Templates, - Videos, WebhooksApi, Workflows, } from './endpoints'; @@ -40,16 +37,17 @@ import { } from './endpoints/types'; import { errorHandlers } from './error-handlers'; import { BannerbearSchema } from './schema'; -import { ImageWebhooks, VideoWebhooks } from './webhooks'; +import { AnimationWebhooks, ImageWebhooks } from './webhooks'; import { matchBannerbearTenantWebhook } from './webhooks/tenant-matcher'; import type { + AnimationCompletedEvent, BannerbearWebhookOutputs, ImageCompletedEvent, - VideoCompletedEvent, } from './webhooks/types'; import { + AnimationCompletedEventSchema, ImageCompletedEventSchema, - VideoCompletedEventSchema, + isBannerbearCompletionPayload, } from './webhooks/types'; export type BannerbearPluginOptions = { @@ -82,52 +80,28 @@ type BannerbearEndpoint = >; export type BannerbearEndpoints = { - // Account getAccountInfo: BannerbearEndpoint<'getAccountInfo'>; getAuth: BannerbearEndpoint<'getAuth'>; - // Projects - listProjects: BannerbearEndpoint<'listProjects'>; - getProject: BannerbearEndpoint<'getProject'>; - createProject: BannerbearEndpoint<'createProject'>; - hydrateProject: BannerbearEndpoint<'hydrateProject'>; - // Templates listTemplates: BannerbearEndpoint<'listTemplates'>; getTemplate: BannerbearEndpoint<'getTemplate'>; createTemplate: BannerbearEndpoint<'createTemplate'>; deleteTemplate: BannerbearEndpoint<'deleteTemplate'>; importTemplate: BannerbearEndpoint<'importTemplate'>; - // Template Sets - listTemplateSets: BannerbearEndpoint<'listTemplateSets'>; - getTemplateSet: BannerbearEndpoint<'getTemplateSet'>; - createTemplateSet: BannerbearEndpoint<'createTemplateSet'>; - updateTemplateSet: BannerbearEndpoint<'updateTemplateSet'>; - // Images listImages: BannerbearEndpoint<'listImages'>; getImage: BannerbearEndpoint<'getImage'>; - // Videos - listVideos: BannerbearEndpoint<'listVideos'>; - listVideoTemplates: BannerbearEndpoint<'listVideoTemplates'>; - createVideoTemplate: BannerbearEndpoint<'createVideoTemplate'>; - // Animated GIFs - listAnimatedGifs: BannerbearEndpoint<'listAnimatedGifs'>; - getAnimatedGif: BannerbearEndpoint<'getAnimatedGif'>; - // Collections - listCollections: BannerbearEndpoint<'listCollections'>; - // Screenshots - listScreenshots: BannerbearEndpoint<'listScreenshots'>; - getScreenshot: BannerbearEndpoint<'getScreenshot'>; - // Signed URLs - getSignedBases: BannerbearEndpoint<'getSignedBases'>; - createSignedBase: BannerbearEndpoint<'createSignedBase'>; - // Webhooks API + createImage: BannerbearEndpoint<'createImage'>; + listAnimations: BannerbearEndpoint<'listAnimations'>; + getAnimation: BannerbearEndpoint<'getAnimation'>; + createAnimation: BannerbearEndpoint<'createAnimation'>; + listAnimationTemplates: BannerbearEndpoint<'listAnimationTemplates'>; + getAnimationTemplate: BannerbearEndpoint<'getAnimationTemplate'>; + createAnimationTemplate: BannerbearEndpoint<'createAnimationTemplate'>; + listInstantUrls: BannerbearEndpoint<'listInstantUrls'>; + createInstantUrl: BannerbearEndpoint<'createInstantUrl'>; getWebhook: BannerbearEndpoint<'getWebhook'>; createWebhook: BannerbearEndpoint<'createWebhook'>; deleteWebhook: BannerbearEndpoint<'deleteWebhook'>; - // Misc - getFonts: BannerbearEndpoint<'getFonts'>; - listEffects: BannerbearEndpoint<'listEffects'>; joinPdfs: BannerbearEndpoint<'joinPdfs'>; - // Workflows listWorkflows: BannerbearEndpoint<'listWorkflows'>; getWorkflow: BannerbearEndpoint<'getWorkflow'>; createWorkflowRun: BannerbearEndpoint<'createWorkflowRun'>; @@ -142,7 +116,10 @@ type BannerbearWebhook< export type BannerbearWebhooks = { imageCompleted: BannerbearWebhook<'imageCompleted', ImageCompletedEvent>; - videoCompleted: BannerbearWebhook<'videoCompleted', VideoCompletedEvent>; + animationCompleted: BannerbearWebhook< + 'animationCompleted', + AnimationCompletedEvent + >; }; export type BannerbearBoundWebhooks = BindWebhooks; @@ -152,12 +129,6 @@ const bannerbearEndpointsNested = { getAccountInfo: Account.getAccountInfo, getAuth: Account.getAuth, }, - projects: { - list: Projects.list, - get: Projects.get, - create: Projects.create, - hydrate: Projects.hydrate, - }, templates: { list: Templates.list, get: Templates.get, @@ -165,35 +136,24 @@ const bannerbearEndpointsNested = { delete: Templates.deleteTemplate, import: Templates.importTemplate, }, - templateSets: { - list: TemplateSets.list, - get: TemplateSets.get, - create: TemplateSets.create, - update: TemplateSets.update, - }, images: { list: Images.list, get: Images.get, - }, - videos: { - listVideos: Videos.listVideos, - listVideoTemplates: Videos.listVideoTemplates, - createVideoTemplate: Videos.createVideoTemplate, + create: Images.create, }, animations: { list: Animations.list, get: Animations.get, + create: Animations.create, }, - collections: { - list: Collections.list, - }, - screenshots: { - list: Screenshots.list, - get: Screenshots.get, + animationTemplates: { + list: AnimationTemplates.list, + get: AnimationTemplates.get, + create: AnimationTemplates.create, }, - signedUrls: { - getSignedBases: SignedUrls.getSignedBases, - createSignedBase: SignedUrls.createSignedBase, + instantUrls: { + list: InstantUrls.list, + create: InstantUrls.create, }, webhooksApi: { get: WebhooksApi.get, @@ -201,8 +161,6 @@ const bannerbearEndpointsNested = { delete: WebhooksApi.deleteWebhook, }, misc: { - getFonts: Misc.getFonts, - listEffects: Misc.listEffects, joinPdfs: Misc.joinPdfs, }, workflows: { @@ -218,8 +176,8 @@ const bannerbearWebhooksNested = { image: { imageCompleted: ImageWebhooks.imageCompleted, }, - video: { - videoCompleted: VideoWebhooks.videoCompleted, + animation: { + animationCompleted: AnimationWebhooks.animationCompleted, }, } as const; @@ -232,22 +190,6 @@ export const bannerbearEndpointSchemas = { input: BannerbearEndpointInputSchemas.getAuth, output: BannerbearEndpointOutputSchemas.getAuth, }, - 'projects.list': { - input: BannerbearEndpointInputSchemas.listProjects, - output: BannerbearEndpointOutputSchemas.listProjects, - }, - 'projects.get': { - input: BannerbearEndpointInputSchemas.getProject, - output: BannerbearEndpointOutputSchemas.getProject, - }, - 'projects.create': { - input: BannerbearEndpointInputSchemas.createProject, - output: BannerbearEndpointOutputSchemas.createProject, - }, - 'projects.hydrate': { - input: BannerbearEndpointInputSchemas.hydrateProject, - output: BannerbearEndpointOutputSchemas.hydrateProject, - }, 'templates.list': { input: BannerbearEndpointInputSchemas.listTemplates, output: BannerbearEndpointOutputSchemas.listTemplates, @@ -268,22 +210,6 @@ export const bannerbearEndpointSchemas = { input: BannerbearEndpointInputSchemas.importTemplate, output: BannerbearEndpointOutputSchemas.importTemplate, }, - 'templateSets.list': { - input: BannerbearEndpointInputSchemas.listTemplateSets, - output: BannerbearEndpointOutputSchemas.listTemplateSets, - }, - 'templateSets.get': { - input: BannerbearEndpointInputSchemas.getTemplateSet, - output: BannerbearEndpointOutputSchemas.getTemplateSet, - }, - 'templateSets.create': { - input: BannerbearEndpointInputSchemas.createTemplateSet, - output: BannerbearEndpointOutputSchemas.createTemplateSet, - }, - 'templateSets.update': { - input: BannerbearEndpointInputSchemas.updateTemplateSet, - output: BannerbearEndpointOutputSchemas.updateTemplateSet, - }, 'images.list': { input: BannerbearEndpointInputSchemas.listImages, output: BannerbearEndpointOutputSchemas.listImages, @@ -292,45 +218,41 @@ export const bannerbearEndpointSchemas = { input: BannerbearEndpointInputSchemas.getImage, output: BannerbearEndpointOutputSchemas.getImage, }, - 'videos.listVideos': { - input: BannerbearEndpointInputSchemas.listVideos, - output: BannerbearEndpointOutputSchemas.listVideos, - }, - 'videos.listVideoTemplates': { - input: BannerbearEndpointInputSchemas.listVideoTemplates, - output: BannerbearEndpointOutputSchemas.listVideoTemplates, - }, - 'videos.createVideoTemplate': { - input: BannerbearEndpointInputSchemas.createVideoTemplate, - output: BannerbearEndpointOutputSchemas.createVideoTemplate, + 'images.create': { + input: BannerbearEndpointInputSchemas.createImage, + output: BannerbearEndpointOutputSchemas.createImage, }, 'animations.list': { - input: BannerbearEndpointInputSchemas.listAnimatedGifs, - output: BannerbearEndpointOutputSchemas.listAnimatedGifs, + input: BannerbearEndpointInputSchemas.listAnimations, + output: BannerbearEndpointOutputSchemas.listAnimations, }, 'animations.get': { - input: BannerbearEndpointInputSchemas.getAnimatedGif, - output: BannerbearEndpointOutputSchemas.getAnimatedGif, + input: BannerbearEndpointInputSchemas.getAnimation, + output: BannerbearEndpointOutputSchemas.getAnimation, }, - 'collections.list': { - input: BannerbearEndpointInputSchemas.listCollections, - output: BannerbearEndpointOutputSchemas.listCollections, + 'animations.create': { + input: BannerbearEndpointInputSchemas.createAnimation, + output: BannerbearEndpointOutputSchemas.createAnimation, }, - 'screenshots.list': { - input: BannerbearEndpointInputSchemas.listScreenshots, - output: BannerbearEndpointOutputSchemas.listScreenshots, + 'animationTemplates.list': { + input: BannerbearEndpointInputSchemas.listAnimationTemplates, + output: BannerbearEndpointOutputSchemas.listAnimationTemplates, }, - 'screenshots.get': { - input: BannerbearEndpointInputSchemas.getScreenshot, - output: BannerbearEndpointOutputSchemas.getScreenshot, + 'animationTemplates.get': { + input: BannerbearEndpointInputSchemas.getAnimationTemplate, + output: BannerbearEndpointOutputSchemas.getAnimationTemplate, }, - 'signedUrls.getSignedBases': { - input: BannerbearEndpointInputSchemas.getSignedBases, - output: BannerbearEndpointOutputSchemas.getSignedBases, + 'animationTemplates.create': { + input: BannerbearEndpointInputSchemas.createAnimationTemplate, + output: BannerbearEndpointOutputSchemas.createAnimationTemplate, }, - 'signedUrls.createSignedBase': { - input: BannerbearEndpointInputSchemas.createSignedBase, - output: BannerbearEndpointOutputSchemas.createSignedBase, + 'instantUrls.list': { + input: BannerbearEndpointInputSchemas.listInstantUrls, + output: BannerbearEndpointOutputSchemas.listInstantUrls, + }, + 'instantUrls.create': { + input: BannerbearEndpointInputSchemas.createInstantUrl, + output: BannerbearEndpointOutputSchemas.createInstantUrl, }, 'webhooksApi.get': { input: BannerbearEndpointInputSchemas.getWebhook, @@ -344,14 +266,6 @@ export const bannerbearEndpointSchemas = { input: BannerbearEndpointInputSchemas.deleteWebhook, output: BannerbearEndpointOutputSchemas.deleteWebhook, }, - 'misc.getFonts': { - input: BannerbearEndpointInputSchemas.getFonts, - output: BannerbearEndpointOutputSchemas.getFonts, - }, - 'misc.listEffects': { - input: BannerbearEndpointInputSchemas.listEffects, - output: BannerbearEndpointOutputSchemas.listEffects, - }, 'misc.joinPdfs': { input: BannerbearEndpointInputSchemas.joinPdfs, output: BannerbearEndpointOutputSchemas.joinPdfs, @@ -386,10 +300,10 @@ const bannerbearWebhookSchemas = { payload: ImageCompletedEventSchema, response: ImageCompletedEventSchema, }, - 'video.videoCompleted': { - description: 'Fires when a Bannerbear video finishes rendering', - payload: VideoCompletedEventSchema, - response: VideoCompletedEventSchema, + 'animation.animationCompleted': { + description: 'Fires when a Bannerbear animation finishes rendering', + payload: AnimationCompletedEventSchema, + response: AnimationCompletedEventSchema, }, } as const satisfies RequiredPluginWebhookSchemas< typeof bannerbearWebhooksNested @@ -404,107 +318,71 @@ const bannerbearEndpointMeta = { }, 'account.getAuth': { riskLevel: 'read', - description: 'Verify API authentication and check project context', - }, - 'projects.list': { - riskLevel: 'read', - description: 'List all projects in the account', - }, - 'projects.get': { - riskLevel: 'read', - description: 'Get a project by UID', - }, - 'projects.create': { - riskLevel: 'write', - description: 'Create a new project', - }, - 'projects.hydrate': { - riskLevel: 'write', - description: 'Hydrate a project by copying templates from another project', + description: 'Verify API authentication against the current account', }, 'templates.list': { riskLevel: 'read', - description: 'List all templates in a project', + description: 'List image templates', }, 'templates.get': { riskLevel: 'read', - description: 'Get a template by UID with layer defaults', + description: 'Get an image template by UID', }, 'templates.create': { riskLevel: 'write', - description: 'Create a new blank template', + description: 'Create an image template', }, 'templates.delete': { riskLevel: 'write', - description: 'Delete a template by UID', + description: 'Delete an image template by UID', }, 'templates.import': { riskLevel: 'write', - description: 'Import a template from the Bannerbear template library', - }, - 'templateSets.list': { - riskLevel: 'read', - description: 'List all template sets in a project', - }, - 'templateSets.get': { - riskLevel: 'read', - description: 'Get a template set by UID', - }, - 'templateSets.create': { - riskLevel: 'write', - description: 'Create a new template set', - }, - 'templateSets.update': { - riskLevel: 'write', - description: 'Update a template set', + description: 'Install a publication as an image template', }, 'images.list': { riskLevel: 'read', - description: 'List all images in a project', + description: 'List generated images', }, 'images.get': { riskLevel: 'read', description: 'Get an image by UID', }, - 'videos.listVideos': { - riskLevel: 'read', - description: 'List all videos in a project', - }, - 'videos.listVideoTemplates': { - riskLevel: 'read', - description: 'List all video templates in a project', - }, - 'videos.createVideoTemplate': { + 'images.create': { riskLevel: 'write', - description: 'Create a new video template', + description: 'Generate an image from a template', }, 'animations.list': { riskLevel: 'read', - description: 'List all animated GIFs in a project', + description: 'List generated animations', }, 'animations.get': { riskLevel: 'read', - description: 'Get an animated GIF by UID', + description: 'Get an animation by UID', }, - 'collections.list': { - riskLevel: 'read', - description: 'List all collections in a project', + 'animations.create': { + riskLevel: 'write', + description: 'Generate an animation from a template', }, - 'screenshots.list': { + 'animationTemplates.list': { riskLevel: 'read', - description: 'List all screenshots in a project', + description: 'List animation templates', }, - 'screenshots.get': { + 'animationTemplates.get': { riskLevel: 'read', - description: 'Get a screenshot by UID', + description: 'Get an animation template by UID', }, - 'signedUrls.getSignedBases': { + 'animationTemplates.create': { + riskLevel: 'write', + description: 'Create an animation template', + }, + 'instantUrls.list': { riskLevel: 'read', - description: 'Get signed URL bases for a template', + description: 'List Instant URLs', }, - 'signedUrls.createSignedBase': { + 'instantUrls.create': { riskLevel: 'write', - description: 'Create a signed URL base for a template', + description: 'Create an Instant URL for an image template', }, 'webhooksApi.get': { riskLevel: 'read', @@ -512,27 +390,19 @@ const bannerbearEndpointMeta = { }, 'webhooksApi.create': { riskLevel: 'write', - description: 'Create a project-level webhook', + description: 'Create a webhook', }, 'webhooksApi.delete': { riskLevel: 'write', description: 'Delete a webhook by UID', }, - 'misc.getFonts': { - riskLevel: 'read', - description: 'Get all available fonts', - }, - 'misc.listEffects': { - riskLevel: 'read', - description: 'List all available image effects', - }, 'misc.joinPdfs': { riskLevel: 'write', - description: 'Merge multiple PDF files into one', + description: 'Merge image or PDF URLs into one PDF', }, 'workflows.listWorkflows': { riskLevel: 'read', - description: 'List all workflows in the workspace', + description: 'List workflows in the workspace', }, 'workflows.getWorkflow': { riskLevel: 'read', @@ -548,7 +418,7 @@ const bannerbearEndpointMeta = { }, 'workflows.listWorkflowRuns': { riskLevel: 'read', - description: 'List all workflow runs', + description: 'List workflow runs', }, } as const satisfies RequiredPluginEndpointMeta< typeof bannerbearEndpointsNested @@ -597,12 +467,8 @@ export function bannerbear( endpointSchemas: bannerbearEndpointSchemas, webhookSchemas: bannerbearWebhookSchemas, pluginWebhookMatcher: (request) => { - const headers = request.headers; - // Bannerbear webhooks are identified by having a uid field in the body - // and coming from Bannerbear's servers. There's no signature header. - // We check for the presence of the body having uid + status fields. - if ('x-bannerbear-signature' in headers) return true; - // Fallback: check body structure for Bannerbear-like payloads + if ('x-bannerbear-signature' in request.headers) return true; + if ('x-webhook-signature' in request.headers) return true; const body = typeof request.body === 'string' ? (() => { @@ -613,12 +479,7 @@ export function bannerbear( } })() : request.body; - return ( - body !== null && - typeof body === 'object' && - 'uid' in body && - 'status' in body - ); + return isBannerbearCompletionPayload(body); }, pluginTenantWebhookMatcher: matchBannerbearTenantWebhook, errorHandlers: { @@ -632,7 +493,10 @@ export function bannerbear( if (source === 'webhook') { const res = await ctx.keys.get_webhook_signature(); - return res ?? ''; + if (!res) { + throw new AuthMissingError('bannerbear', 'webhook_signature'); + } + return res; } if (source === 'endpoint' && options.key) { @@ -641,10 +505,13 @@ export function bannerbear( if (source === 'endpoint' && ctx.authType === 'api_key') { const res = await ctx.keys.get_api_key(); - return res ?? ''; + if (!res) { + throw new AuthMissingError('bannerbear', 'api_key'); + } + return res; } - return ''; + throw new AuthMissingError('bannerbear', 'api_key'); }, } satisfies InternalBannerbearPlugin; } @@ -652,16 +519,16 @@ export function bannerbear( export type { BannerbearEndpointInputs, BannerbearEndpointOutputs, - CreateProjectInput, - CreateProjectResponse, - CreateSignedBaseInput, - CreateSignedBaseResponse, + CreateAnimationInput, + CreateAnimationResponse, + CreateAnimationTemplateInput, + CreateAnimationTemplateResponse, + CreateImageInput, + CreateImageResponse, + CreateInstantUrlInput, + CreateInstantUrlResponse, CreateTemplateInput, CreateTemplateResponse, - CreateTemplateSetInput, - CreateTemplateSetResponse, - CreateVideoTemplateInput, - CreateVideoTemplateResponse, CreateWebhookInput, CreateWebhookResponse, CreateWorkflowRunInput, @@ -672,65 +539,43 @@ export type { DeleteWebhookResponse, GetAccountInfoInput, GetAccountInfoResponse, - GetAnimatedGifInput, - GetAnimatedGifResponse, + GetAnimationInput, + GetAnimationResponse, + GetAnimationTemplateInput, + GetAnimationTemplateResponse, GetAuthInput, GetAuthResponse, - GetFontsInput, - GetFontsResponse, GetImageInput, GetImageResponse, - GetProjectInput, - GetProjectResponse, - GetScreenshotInput, - GetScreenshotResponse, - GetSignedBasesInput, - GetSignedBasesResponse, GetTemplateInput, GetTemplateResponse, - GetTemplateSetInput, - GetTemplateSetResponse, GetWebhookInput, GetWebhookResponse, GetWorkflowInput, GetWorkflowResponse, GetWorkflowRunInput, GetWorkflowRunResponse, - HydrateProjectInput, - HydrateProjectResponse, ImportTemplateInput, ImportTemplateResponse, JoinPdfsInput, JoinPdfsResponse, - ListAnimatedGifsInput, - ListAnimatedGifsResponse, - ListCollectionsInput, - ListCollectionsResponse, - ListEffectsInput, - ListEffectsResponse, + ListAnimationsInput, + ListAnimationsResponse, + ListAnimationTemplatesInput, + ListAnimationTemplatesResponse, ListImagesInput, ListImagesResponse, - ListProjectsInput, - ListProjectsResponse, - ListScreenshotsInput, - ListScreenshotsResponse, - ListTemplateSetsInput, - ListTemplateSetsResponse, + ListInstantUrlsInput, + ListInstantUrlsResponse, ListTemplatesInput, ListTemplatesResponse, - ListVideosInput, - ListVideosResponse, - ListVideoTemplatesInput, - ListVideoTemplatesResponse, ListWorkflowRunsInput, ListWorkflowRunsResponse, ListWorkflowsInput, ListWorkflowsResponse, - UpdateTemplateSetInput, - UpdateTemplateSetResponse, } from './endpoints/types'; export type { + AnimationCompletedEvent, BannerbearWebhookOutputs, ImageCompletedEvent, - VideoCompletedEvent, } from './webhooks/types'; diff --git a/packages/bannerbear/schema/database.ts b/packages/bannerbear/schema/database.ts index 30bf4478b..7baf19422 100644 --- a/packages/bannerbear/schema/database.ts +++ b/packages/bannerbear/schema/database.ts @@ -24,36 +24,17 @@ export const BannerbearAccount = z.object({ }); export type BannerbearAccount = z.infer; -export const BannerbearProject = z.object({ - uid: z.string(), - name: z.string(), - created_at: z.string().nullable().optional(), - templates_count: z.number().nullable().optional(), - api_key: z.string().nullable().optional(), -}); -export type BannerbearProject = z.infer; - -export const BannerbearTemplateModification = z.object({ - name: z.string(), - type: z.string().nullable().optional(), - text: z.string().nullable().optional(), - color: z.string().nullable().optional(), - image_url: z.string().nullable().optional(), -}); -export type BannerbearTemplateModification = z.infer< - typeof BannerbearTemplateModification ->; - export const BannerbearTemplate = z.object({ uid: z.string(), name: z.string(), + description: z.string().nullable().optional(), width: z.number().nullable().optional(), height: z.number().nullable().optional(), + preview: z.string().nullable().optional(), preview_url: z.string().nullable().optional(), created_at: z.string().nullable().optional(), updated_at: z.string().nullable().optional(), tags: z.array(z.string()).optional(), - modifications: z.array(BannerbearTemplateModification).optional(), self: z.string().nullable().optional(), }); export type BannerbearTemplate = z.infer; @@ -62,92 +43,81 @@ export const BannerbearImage = z.object({ uid: z.string(), status: z.string(), template: z.string().nullable().optional(), - files: z - .object({ - png: z.string().nullable().optional(), - jpg: z.string().nullable().optional(), - pdf: z.string().nullable().optional(), - webp: z.string().nullable().optional(), - avif: z.string().nullable().optional(), - }) - .nullable() - .optional(), + files: z.record(z.string(), z.string().nullable()).nullable().optional(), metadata: z.string().nullable().optional(), + error: z.string().nullable().optional(), self: z.string().nullable().optional(), created_at: z.string().nullable().optional(), completed_at: z.string().nullable().optional(), }); export type BannerbearImage = z.infer; -export const BannerbearVideo = z.object({ +export const BannerbearAnimation = z.object({ uid: z.string(), status: z.string(), - video_url: z.string().nullable().optional(), - preview_url: z.string().nullable().optional(), - percent_rendered: z.number().nullable().optional(), + template: z.string().nullable().optional(), + files: z.record(z.string(), z.string().nullable()).nullable().optional(), + progress: z.number().nullable().optional(), + metadata: z.string().nullable().optional(), + error: z.string().nullable().optional(), self: z.string().nullable().optional(), created_at: z.string().nullable().optional(), completed_at: z.string().nullable().optional(), }); -export type BannerbearVideo = z.infer; +export type BannerbearAnimation = z.infer; -export const BannerbearAnimatedGif = z.object({ +export const BannerbearAnimationTemplate = z.object({ uid: z.string(), - status: z.string(), - image_url: z.string().nullable().optional(), - self: z.string().nullable().optional(), + name: z.string(), + description: z.string().nullable().optional(), + width: z.number().nullable().optional(), + height: z.number().nullable().optional(), + frame_rate: z.number().nullable().optional(), + duration_seconds: z.number().nullable().optional(), + preview: z.string().nullable().optional(), + tags: z.array(z.string()).optional(), created_at: z.string().nullable().optional(), - completed_at: z.string().nullable().optional(), -}); -export type BannerbearAnimatedGif = z.infer; - -export const BannerbearCollection = z.object({ - uid: z.string(), - status: z.string(), - template_set: z.string().nullable().optional(), - images: z.array(z.string()).nullable().optional(), - image_urls: z.record(z.string(), z.string().nullable()).nullable().optional(), self: z.string().nullable().optional(), - created_at: z.string().nullable().optional(), - completed_at: z.string().nullable().optional(), }); -export type BannerbearCollection = z.infer; +export type BannerbearAnimationTemplate = z.infer< + typeof BannerbearAnimationTemplate +>; -export const BannerbearScreenshot = z.object({ +export const BannerbearInstantUrl = z.object({ uid: z.string(), - status: z.string(), - screenshot_url: z.string().nullable().optional(), - width: z.number().nullable().optional(), - height: z.number().nullable().optional(), - self: z.string().nullable().optional(), + name: z.string().optional(), + template: z.string().nullable().optional(), + base_url: z.string().optional(), + sample_url: z.string().nullable().optional(), + mode: z.string().nullable().optional(), + security: z.string().nullable().optional(), + status: z.string().nullable().optional(), + signing_key: z.string().nullable().optional(), created_at: z.string().nullable().optional(), - completed_at: z.string().nullable().optional(), }); -export type BannerbearScreenshot = z.infer; +export type BannerbearInstantUrl = z.infer; -export const BannerbearTemplateSet = z.object({ +export const BannerbearWebhookObj = z.object({ uid: z.string(), - name: z.string(), - templates: z.array(z.string()).nullable().optional(), - available_modifications: z - .array(BannerbearTemplateModification) - .nullable() - .optional(), + name: z.string().optional(), + url: z.string(), + resource: z.string().nullable().optional(), + event: z.string().nullable().optional(), + status: z.string().nullable().optional(), + signing_key: z.string().nullable().optional(), created_at: z.string().nullable().optional(), - self: z.string().nullable().optional(), }); -export type BannerbearTemplateSet = z.infer; +export type BannerbearWebhookObj = z.infer; -export const BannerbearVideoTemplate = z.object({ +export const BannerbearPdfJoin = z.object({ uid: z.string(), - name: z.string(), - width: z.number().nullable().optional(), - height: z.number().nullable().optional(), - created_at: z.string().nullable().optional(), - updated_at: z.string().nullable().optional(), + status: z.string(), + joined_pdf_url: z.string().nullable().optional(), self: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + metadata: z.string().nullable().optional(), }); -export type BannerbearVideoTemplate = z.infer; +export type BannerbearPdfJoin = z.infer; export const BannerbearWorkflow = z.object({ uid: z.string(), @@ -176,41 +146,3 @@ export const BannerbearWorkflowRun = z.object({ completed_at: z.string().nullable().optional(), }); export type BannerbearWorkflowRun = z.infer; - -export const BannerbearWebhookObj = z.object({ - uid: z.string(), - url: z.string(), - event: z.string().nullable().optional(), - active: z.boolean().nullable().optional(), - created_at: z.string().nullable().optional(), -}); -export type BannerbearWebhookObj = z.infer; - -export const BannerbearSignedBase = z.object({ - base_url: z.string(), - template: z.string().nullable().optional(), - created_at: z.string().nullable().optional(), -}); -export type BannerbearSignedBase = z.infer; - -export const BannerbearFont = z.object({ - name: z.string(), - family: z.string().nullable().optional(), - category: z.string().nullable().optional(), -}); -export type BannerbearFont = z.infer; - -export const BannerbearEffect = z.object({ - name: z.string(), - slug: z.string().nullable().optional(), -}); -export type BannerbearEffect = z.infer; - -export const BannerbearPdfJoin = z.object({ - uid: z.string(), - status: z.string(), - joined_pdf_url: z.string().nullable().optional(), - self: z.string().nullable().optional(), - created_at: z.string().nullable().optional(), -}); -export type BannerbearPdfJoin = z.infer; diff --git a/packages/bannerbear/schema/index.ts b/packages/bannerbear/schema/index.ts index 0be654a3c..5c95ef53d 100644 --- a/packages/bannerbear/schema/index.ts +++ b/packages/bannerbear/schema/index.ts @@ -1,44 +1,4 @@ -import { - BannerbearAccount, - BannerbearAnimatedGif, - BannerbearCollection, - BannerbearEffect, - BannerbearFont, - BannerbearImage, - BannerbearPdfJoin, - BannerbearProject, - BannerbearScreenshot, - BannerbearSignedBase, - BannerbearTemplate, - BannerbearTemplateModification, - BannerbearTemplateSet, - BannerbearVideo, - BannerbearVideoTemplate, - BannerbearWebhookObj, - BannerbearWorkflow, - BannerbearWorkflowRun, -} from './database'; - export const BannerbearSchema = { version: '1.0.0', - entities: { - account: BannerbearAccount, - project: BannerbearProject, - template: BannerbearTemplate, - templateModification: BannerbearTemplateModification, - image: BannerbearImage, - video: BannerbearVideo, - animatedGif: BannerbearAnimatedGif, - collection: BannerbearCollection, - screenshot: BannerbearScreenshot, - templateSet: BannerbearTemplateSet, - videoTemplate: BannerbearVideoTemplate, - workflow: BannerbearWorkflow, - workflowRun: BannerbearWorkflowRun, - webhookObj: BannerbearWebhookObj, - signedBase: BannerbearSignedBase, - font: BannerbearFont, - effect: BannerbearEffect, - pdfJoin: BannerbearPdfJoin, - }, + entities: {}, } as const; diff --git a/packages/bannerbear/webhooks/video-completed.ts b/packages/bannerbear/webhooks/animation-completed.ts similarity index 76% rename from packages/bannerbear/webhooks/video-completed.ts rename to packages/bannerbear/webhooks/animation-completed.ts index 93953307f..f6cbb7f97 100644 --- a/packages/bannerbear/webhooks/video-completed.ts +++ b/packages/bannerbear/webhooks/animation-completed.ts @@ -1,12 +1,12 @@ import { logEventFromContext } from 'corsair/core'; import type { BannerbearWebhooks } from '..'; import { - createBannerbearVideoCompletedMatch, + createBannerbearAnimationCompletedMatch, verifyBannerbearWebhookSignature, } from './types'; -export const videoCompleted: BannerbearWebhooks['videoCompleted'] = { - match: createBannerbearVideoCompletedMatch(), +export const animationCompleted: BannerbearWebhooks['animationCompleted'] = { + match: createBannerbearAnimationCompletedMatch(), handler: async (ctx, request) => { const verification = verifyBannerbearWebhookSignature(request, ctx.key); @@ -25,7 +25,7 @@ export const videoCompleted: BannerbearWebhooks['videoCompleted'] = { await logEventFromContext( ctx, - 'bannerbear.webhook.video_completed', + 'bannerbear.webhook.animation_completed', { uid: event.uid }, 'completed', ); diff --git a/packages/bannerbear/webhooks/index.ts b/packages/bannerbear/webhooks/index.ts index da7316cc7..4970a0ce3 100644 --- a/packages/bannerbear/webhooks/index.ts +++ b/packages/bannerbear/webhooks/index.ts @@ -1,12 +1,12 @@ +import { animationCompleted } from './animation-completed'; import { imageCompleted } from './image-completed'; -import { videoCompleted } from './video-completed'; export const ImageWebhooks = { imageCompleted, }; -export const VideoWebhooks = { - videoCompleted, +export const AnimationWebhooks = { + animationCompleted, }; export * from './tenant-matcher'; diff --git a/packages/bannerbear/webhooks/tenant-matcher.ts b/packages/bannerbear/webhooks/tenant-matcher.ts index c02bd942c..b3eb9f91b 100644 --- a/packages/bannerbear/webhooks/tenant-matcher.ts +++ b/packages/bannerbear/webhooks/tenant-matcher.ts @@ -1,9 +1,5 @@ import type { RawWebhookRequest, WebhookTenantMatch } from 'corsair/core'; -// Bannerbear webhook payloads contain the completed resource objects (e.g. image, video) -// rather than an owning account ID. Routing uses the per-webhook URL configured -// at the project level or per-request callback URL. -// See https://developers.bannerbear.com/v5/#webhooks export function matchBannerbearTenantWebhook( _request: RawWebhookRequest, ): WebhookTenantMatch | null { diff --git a/packages/bannerbear/webhooks/types.ts b/packages/bannerbear/webhooks/types.ts index 7f4806252..4bac46aa6 100644 --- a/packages/bannerbear/webhooks/types.ts +++ b/packages/bannerbear/webhooks/types.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from 'node:crypto'; import type { CorsairWebhookMatcher, RawWebhookRequest, @@ -6,10 +7,6 @@ import type { import { verifyHmacSignature } from 'corsair/http'; import { z } from 'zod'; -// Bannerbear webhook payloads are the completed resource objects themselves. -// When an image/video/collection completes, Bannerbear POSTs the final object -// to the webhook_url that was provided at creation time. - export const BannerbearWebhookPayloadSchema = z .object({ uid: z.string(), @@ -23,19 +20,12 @@ export type BannerbearWebhookPayload = z.infer< typeof BannerbearWebhookPayloadSchema >; -// ─── Image Completed Event ─────────────────────────────────── export const ImageCompletedEventSchema = z .object({ uid: z.string(), status: z.literal('completed'), template: z.string().optional(), - files: z - .object({ - png: z.string().optional(), - jpg: z.string().optional(), - pdf: z.string().optional(), - }) - .optional(), + files: z.record(z.string(), z.string().nullable()).optional(), metadata: z.string().optional(), self: z.string().optional(), created_at: z.string().optional(), @@ -45,29 +35,29 @@ export const ImageCompletedEventSchema = z export type ImageCompletedEvent = z.infer; -// ─── Video Completed Event ─────────────────────────────────── -export const VideoCompletedEventSchema = z +export const AnimationCompletedEventSchema = z .object({ uid: z.string(), status: z.literal('completed'), - video_url: z.string().optional(), - preview_url: z.string().optional(), - percent_rendered: z.number().optional(), + template: z.string().optional(), + files: z.record(z.string(), z.string().nullable()).optional(), + progress: z.number().optional(), + metadata: z.string().optional(), self: z.string().optional(), created_at: z.string().optional(), completed_at: z.string().optional(), }) .loose(); -export type VideoCompletedEvent = z.infer; +export type AnimationCompletedEvent = z.infer< + typeof AnimationCompletedEventSchema +>; -// ─── Aggregate types ───────────────────────────────────────── export type BannerbearWebhookOutputs = { imageCompleted: ImageCompletedEvent; - videoCompleted: VideoCompletedEvent; + animationCompleted: AnimationCompletedEvent; }; -// ─── Matchers & Verification ───────────────────────────────── function parseBody(body: unknown): Record | null { if (typeof body === 'string') { try { @@ -86,24 +76,56 @@ function parseBody(body: unknown): Record | null { : null; } +function fileKeys(files: unknown): string[] { + if (files === null || typeof files !== 'object' || Array.isArray(files)) { + return []; + } + return Object.keys(files); +} + +function isAnimationFiles(files: unknown): boolean { + const keys = fileKeys(files); + return keys.includes('mp4') || keys.includes('mov'); +} + export function createBannerbearImageCompletedMatch(): CorsairWebhookMatcher { return (request: RawWebhookRequest) => { const parsedBody = parseBody(request.body); if (!parsedBody) return false; - // Image completed: has status=completed and files field (images have files, videos have video_url) - return parsedBody.status === 'completed' && 'files' in parsedBody; + return ( + parsedBody.status === 'completed' && + 'files' in parsedBody && + !isAnimationFiles(parsedBody.files) + ); }; } -export function createBannerbearVideoCompletedMatch(): CorsairWebhookMatcher { +export function createBannerbearAnimationCompletedMatch(): CorsairWebhookMatcher { return (request: RawWebhookRequest) => { const parsedBody = parseBody(request.body); if (!parsedBody) return false; - // Video completed: has status=completed and video_url field - return parsedBody.status === 'completed' && 'video_url' in parsedBody; + return ( + parsedBody.status === 'completed' && isAnimationFiles(parsedBody.files) + ); }; } +function headerValue( + headers: WebhookRequest['headers'], + name: string, +): string | undefined { + const value = headers[name]; + if (Array.isArray(value)) return value[0]; + return value; +} + +function safeEqual(left: string, right: string): boolean { + const a = Buffer.from(left); + const b = Buffer.from(right); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + export function verifyBannerbearWebhookSignature( request: WebhookRequest, webhookSecret?: string, @@ -116,29 +138,21 @@ export function verifyBannerbearWebhookSignature( return { valid: false, error: 'Missing webhook secret' }; } - const headers = request.headers; - - // 1. Check Bannerbear Webhook Key in Authorization header (Bannerbear native) - const authHeader = Array.isArray(headers.authorization) - ? headers.authorization[0] - : headers.authorization; + const authHeader = headerValue(request.headers, 'authorization'); if (authHeader) { const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : authHeader.trim(); - if (token === webhookSecret) { + if (safeEqual(token, webhookSecret)) { return { valid: true }; } } - // 2. Check HMAC signature header (x-bannerbear-signature / signature) - const signature = Array.isArray(headers['x-bannerbear-signature']) - ? headers['x-bannerbear-signature'][0] - : headers['x-bannerbear-signature'] || - (Array.isArray(headers.signature) - ? headers.signature[0] - : headers.signature); + const signature = + headerValue(request.headers, 'x-bannerbear-signature') || + headerValue(request.headers, 'x-webhook-signature') || + headerValue(request.headers, 'signature'); if (signature && request.rawBody) { const isValid = verifyHmacSignature( @@ -155,9 +169,19 @@ export function verifyBannerbearWebhookSignature( if (!authHeader && !signature) { return { valid: false, - error: 'Missing Authorization or x-bannerbear-signature header', + error: 'Missing Authorization or webhook signature header', }; } return { valid: false, error: 'Invalid webhook credentials or signature' }; } + +export function isBannerbearCompletionPayload(body: unknown): boolean { + const parsed = parseBody(body); + if (!parsed) return false; + return ( + typeof parsed.uid === 'string' && + typeof parsed.status === 'string' && + 'files' in parsed + ); +} From 72ffa6ac01dc8cc53a510e8cbd4f6f9d1b7bcf1c Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Sun, 23 Aug 2026 16:21:42 +0530 Subject: [PATCH 11/11] test(bannerbear): cover V5 paths, auth, and retries --- packages/bannerbear/endpoints.test.ts | 701 ++++++++++---------------- packages/bannerbear/webhooks.test.ts | 46 +- 2 files changed, 291 insertions(+), 456 deletions(-) diff --git a/packages/bannerbear/endpoints.test.ts b/packages/bannerbear/endpoints.test.ts index c6daeec6e..d78ff5344 100644 --- a/packages/bannerbear/endpoints.test.ts +++ b/packages/bannerbear/endpoints.test.ts @@ -1,5 +1,7 @@ +import { AuthMissingError } from 'corsair/core'; import { ApiError, request } from 'corsair/http'; import { makeBannerbearRequest } from './client'; +import { errorHandlers } from './error-handlers'; import type { BannerbearContext } from './index'; import { bannerbear, bannerbearEndpointSchemas } from './index'; @@ -27,11 +29,35 @@ const mockCtx = { options: {}, keys: { get_api_key: jest.fn().mockResolvedValue('bb_test_api_key_123'), + get_webhook_signature: jest.fn().mockResolvedValue('whsec_test'), }, logEvent: jest.fn(), database: {}, } as unknown as BannerbearContext; +function expectRequest( + method: string, + url: string, + extra: Record = {}, +) { + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://api.bannerbear.com', + HEADERS: expect.objectContaining({ + Authorization: 'Bearer bb_test_api_key_123', + }), + }), + expect.objectContaining({ + method, + url, + ...extra, + }), + expect.objectContaining({ + rateLimitConfig: expect.objectContaining({ maxRetries: 0 }), + }), + ); +} + describe('Bannerbear plugin registry', () => { const plugin = bannerbear(); @@ -50,9 +76,38 @@ describe('Bannerbear plugin registry', () => { return paths.sort(); } - it('registers all 38 operations in the endpoint tree', () => { + it('registers all V5 operations in the endpoint tree', () => { const paths = nestedPaths(); - expect(paths).toHaveLength(38); + expect(paths).toHaveLength(27); + expect(paths).toEqual([ + 'account.getAccountInfo', + 'account.getAuth', + 'animationTemplates.create', + 'animationTemplates.get', + 'animationTemplates.list', + 'animations.create', + 'animations.get', + 'animations.list', + 'images.create', + 'images.get', + 'images.list', + 'instantUrls.create', + 'instantUrls.list', + 'misc.joinPdfs', + 'templates.create', + 'templates.delete', + 'templates.get', + 'templates.import', + 'templates.list', + 'webhooksApi.create', + 'webhooksApi.delete', + 'webhooksApi.get', + 'workflows.createWorkflowRun', + 'workflows.getWorkflow', + 'workflows.getWorkflowRun', + 'workflows.listWorkflowRuns', + 'workflows.listWorkflows', + ]); }); it('keeps the endpoint tree, schemas, and metadata in lockstep', () => { @@ -81,9 +136,25 @@ describe('Bannerbear plugin registry', () => { expect(meta.description?.length).toBeGreaterThan(0); } }); + + it('throws AuthMissingError when no API key is configured', async () => { + await expect( + plugin.keyBuilder!( + { + ...mockCtx, + authType: 'api_key', + keys: { + get_api_key: jest.fn().mockResolvedValue(undefined), + get_webhook_signature: jest.fn(), + }, + } as unknown as Parameters>[0], + 'endpoint', + ), + ).rejects.toBeInstanceOf(AuthMissingError); + }); }); -describe('Bannerbear client error wrapping', () => { +describe('Bannerbear client error wrapping and retries', () => { beforeEach(() => { mockRequest.mockReset(); }); @@ -103,7 +174,10 @@ describe('Bannerbear client error wrapping', () => { mockRequest.mockRejectedValue(apiError); await expect( - makeBannerbearRequest('/v5/images', 'test-key', { method: 'GET' }), + makeBannerbearRequest('/v5/images', 'test-key', { + method: 'GET', + retries: false, + }), ).rejects.toThrow(apiError); }); @@ -117,6 +191,73 @@ describe('Bannerbear client error wrapping', () => { message: 'Network error', }); }); + + it('retries GET 429s inside the client', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + { retryAfter: 0 }, + ); + mockRequest + .mockRejectedValueOnce(apiError) + .mockResolvedValueOnce([{ uid: 'img_1' }]); + + const result = await makeBannerbearRequest('/v5/images', 'test-key', { + method: 'GET', + }); + expect(result).toEqual([{ uid: 'img_1' }]); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it('does not retry POST requests', async () => { + const apiError = new ApiError( + { method: 'POST', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + ); + mockRequest.mockRejectedValue(apiError); + + await expect( + makeBannerbearRequest('/v5/images', 'test-key', { + method: 'POST', + body: { template: 'tmpl_1', modifications: {} }, + }), + ).rejects.toThrow(apiError); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('Bannerbear binder error handlers', () => { + it('keeps 429 binder retries at zero', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://api.bannerbear.com/v5/images' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://api.bannerbear.com/v5/images', + body: {}, + }, + 'Too Many Requests', + ); + expect(errorHandlers.RATE_LIMIT_ERROR.match(apiError)).toBe(true); + await expect( + errorHandlers.RATE_LIMIT_ERROR.handler(apiError), + ).resolves.toMatchObject({ maxRetries: 0 }); + }); }); describe('Bannerbear endpoint handler invocations', () => { @@ -127,135 +268,33 @@ describe('Bannerbear endpoint handler invocations', () => { mockRequest.mockReset(); }); - // Account it('invokes account.getAccountInfo', async () => { mockRequest.mockResolvedValue({ uid: 'acc_1', plan: 'pro' }); - const result = await endpoints.account.getAccountInfo(mockCtx, { - project_id: 'proj_1', - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.objectContaining({ - BASE: 'https://api.bannerbear.com', - HEADERS: expect.objectContaining({ - Authorization: 'Bearer bb_test_api_key_123', - }), - }), - expect.objectContaining({ - method: 'GET', - url: '/v5/account', - query: { project_id: 'proj_1' }, - }), - ); + const result = await endpoints.account.getAccountInfo(mockCtx, {}); + expectRequest('GET', '/v5/account'); expect(result).toEqual({ uid: 'acc_1', plan: 'pro' }); }); it('invokes account.getAuth', async () => { mockRequest.mockResolvedValue({ uid: 'acc_1' }); const result = await endpoints.account.getAuth(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/account', - }), - ); + expectRequest('GET', '/v5/account'); expect(result).toEqual({ uid: 'acc_1' }); }); - // Projects - it('invokes projects.list', async () => { - mockRequest.mockResolvedValue([{ uid: 'proj_1', name: 'Project 1' }]); - const result = await endpoints.projects.list(mockCtx, { page: 2 }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/projects', - query: { page: 2 }, - }), - ); - expect(result).toHaveLength(1); - }); - - it('invokes projects.get', async () => { - mockRequest.mockResolvedValue({ uid: 'proj_1', name: 'Project 1' }); - const result = await endpoints.projects.get(mockCtx, { uid: 'proj_1' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/projects/proj_1', - }), - ); - expect(result.name).toBe('Project 1'); - }); - - it('invokes projects.create', async () => { - mockRequest.mockResolvedValue({ uid: 'proj_new', name: 'New Project' }); - const result = await endpoints.projects.create(mockCtx, { - name: 'New Project', - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/projects', - body: { name: 'New Project' }, - }), - ); - expect(result.uid).toBe('proj_new'); - }); - - it('invokes projects.hydrate', async () => { - mockRequest.mockResolvedValue({ uid: 'proj_1', name: 'Hydrated' }); - const result = await endpoints.projects.hydrate(mockCtx, { - uid: 'proj_1', - source_project: 'src_proj', - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/projects/proj_1/hydrate', - body: { source_project: 'src_proj' }, - }), - ); - expect(result.uid).toBe('proj_1'); - }); - - // Templates it('invokes templates.list', async () => { mockRequest.mockResolvedValue([{ uid: 'tmpl_1', name: 'Template 1' }]); - const result = await endpoints.templates.list(mockCtx, { - page: 1, - limit: 10, - project_id: 'p1', - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/templates', - query: { page: 1, limit: 10, project_id: 'p1' }, - }), - ); + const result = await endpoints.templates.list(mockCtx, { page: 1 }); + expectRequest('GET', '/v5/image_templates', { query: { page: 1 } }); expect(result).toHaveLength(1); }); - it('invokes templates.get', async () => { - mockRequest.mockResolvedValue({ uid: 'tmpl_1', name: 'Template 1' }); + it('invokes templates.get with encoded uid', async () => { + mockRequest.mockResolvedValue({ uid: 'tmpl/1', name: 'Template 1' }); const result = await endpoints.templates.get(mockCtx, { - uid: 'tmpl_1', - project_id: 'p1', + uid: 'tmpl/1', }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/templates/tmpl_1', - query: { project_id: 'p1' }, - }), - ); + expectRequest('GET', '/v5/image_templates/tmpl%2F1'); expect(result.name).toBe('Template 1'); }); @@ -270,16 +309,10 @@ describe('Bannerbear endpoint handler invocations', () => { name: 'T2', width: 1200, height: 630, - project_id: 'p1', }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/templates', - body: { name: 'T2', width: 1200, height: 630, project_id: 'p1' }, - }), - ); + expectRequest('POST', '/v5/image_templates', { + body: { name: 'T2', width: 1200, height: 630 }, + }); expect(result.width).toBe(1200); }); @@ -287,345 +320,171 @@ describe('Bannerbear endpoint handler invocations', () => { mockRequest.mockResolvedValue(undefined); const result = await endpoints.templates.delete(mockCtx, { uid: 'tmpl_1', - project_id: 'p1', - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'DELETE', - url: '/v5/templates/tmpl_1', - query: { project_id: 'p1' }, - }), - ); + }); + expectRequest('DELETE', '/v5/image_templates/tmpl_1'); expect(result).toEqual({ success: true }); }); - it('invokes templates.import', async () => { + it('invokes templates.import via publications install', async () => { mockRequest.mockResolvedValue({ uid: 'tmpl_imp', name: 'Imported' }); const result = await endpoints.templates.import(mockCtx, { publication_id: 'pub_123', - project_id: 'p1', }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/templates/import', - body: { publication_id: 'pub_123', project_id: 'p1' }, - }), - ); + expectRequest('POST', '/v5/publications/pub_123/install', { body: {} }); expect(result.uid).toBe('tmpl_imp'); }); - // Template Sets - it('invokes templateSets.list', async () => { - mockRequest.mockResolvedValue([{ uid: 'ts_1', name: 'Set 1' }]); - const result = await endpoints.templateSets.list(mockCtx, { page: 1 }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/template_sets', - }), - ); - expect(result).toHaveLength(1); - }); - - it('invokes templateSets.get', async () => { - mockRequest.mockResolvedValue({ uid: 'ts_1', name: 'Set 1' }); - const result = await endpoints.templateSets.get(mockCtx, { uid: 'ts_1' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/template_sets/ts_1', - }), - ); - expect(result.uid).toBe('ts_1'); - }); - - it('invokes templateSets.create', async () => { - mockRequest.mockResolvedValue({ uid: 'ts_new', name: 'Set New' }); - const result = await endpoints.templateSets.create(mockCtx, { - name: 'Set New', - templates: ['tmpl_1', 'tmpl_2'], - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/template_sets', - body: { name: 'Set New', templates: ['tmpl_1', 'tmpl_2'] }, - }), - ); - expect(result.uid).toBe('ts_new'); - }); - - it('invokes templateSets.update', async () => { - mockRequest.mockResolvedValue({ uid: 'ts_1', name: 'Set 1' }); - const result = await endpoints.templateSets.update(mockCtx, { - uid: 'ts_1', - templates: ['tmpl_3'], - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'PATCH', - url: '/v5/template_sets/ts_1', - body: { templates: ['tmpl_3'] }, - }), - ); - expect(result.uid).toBe('ts_1'); - }); - - // Images it('invokes images.list', async () => { mockRequest.mockResolvedValue([{ uid: 'img_1', status: 'completed' }]); const result = await endpoints.images.list(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/images', - }), - ); + expectRequest('GET', '/v5/images'); expect(result).toHaveLength(1); }); it('invokes images.get', async () => { mockRequest.mockResolvedValue({ uid: 'img_1', status: 'completed' }); const result = await endpoints.images.get(mockCtx, { uid: 'img_1' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/images/img_1', - }), - ); + expectRequest('GET', '/v5/images/img_1'); expect(result.uid).toBe('img_1'); }); - // Videos - it('invokes videos.listVideos', async () => { - mockRequest.mockResolvedValue([{ uid: 'vid_1', status: 'completed' }]); - const result = await endpoints.videos.listVideos(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/videos', - }), - ); - expect(result).toHaveLength(1); - }); - - it('invokes videos.listVideoTemplates', async () => { - mockRequest.mockResolvedValue([{ uid: 'vt_1', name: 'VT 1' }]); - const result = await endpoints.videos.listVideoTemplates(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/video_templates', - }), - ); - expect(result).toHaveLength(1); - }); - - it('invokes videos.createVideoTemplate', async () => { - mockRequest.mockResolvedValue({ uid: 'vt_new', name: 'New VT' }); - const result = await endpoints.videos.createVideoTemplate(mockCtx, { - name: 'New VT', + it('invokes images.create', async () => { + mockRequest.mockResolvedValue({ uid: 'img_new', status: 'pending' }); + const result = await endpoints.images.create(mockCtx, { template: 'tmpl_1', + modifications: { objects: [] }, }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/video_templates', - body: { name: 'New VT', template: 'tmpl_1' }, - }), - ); - expect(result.uid).toBe('vt_new'); + expectRequest('POST', '/v5/images', { + body: { + template: 'tmpl_1', + modifications: { objects: [] }, + }, + }); + expect(result.uid).toBe('img_new'); }); - // Animations it('invokes animations.list', async () => { - mockRequest.mockResolvedValue([{ uid: 'gif_1', status: 'completed' }]); + mockRequest.mockResolvedValue([{ uid: 'an_1', status: 'completed' }]); const result = await endpoints.animations.list(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/animated_gifs', - }), - ); + expectRequest('GET', '/v5/animations'); expect(result).toHaveLength(1); }); it('invokes animations.get', async () => { - mockRequest.mockResolvedValue({ uid: 'gif_1', status: 'completed' }); - const result = await endpoints.animations.get(mockCtx, { uid: 'gif_1' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/animated_gifs/gif_1', - }), - ); - expect(result.uid).toBe('gif_1'); + mockRequest.mockResolvedValue({ uid: 'an_1', status: 'completed' }); + const result = await endpoints.animations.get(mockCtx, { uid: 'an_1' }); + expectRequest('GET', '/v5/animations/an_1'); + expect(result.uid).toBe('an_1'); + }); + + it('invokes animations.create', async () => { + mockRequest.mockResolvedValue({ uid: 'an_new', status: 'queued' }); + const result = await endpoints.animations.create(mockCtx, { + template: 'at_1', + modifications: { objects: [] }, + formats: ['mp4'], + }); + expectRequest('POST', '/v5/animations', { + body: { + template: 'at_1', + modifications: { objects: [] }, + formats: ['mp4'], + }, + }); + expect(result.uid).toBe('an_new'); }); - // Collections - it('invokes collections.list', async () => { - mockRequest.mockResolvedValue([{ uid: 'col_1', status: 'completed' }]); - const result = await endpoints.collections.list(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/collections', - }), - ); + it('invokes animationTemplates.list', async () => { + mockRequest.mockResolvedValue([{ uid: 'at_1', name: 'AT 1' }]); + const result = await endpoints.animationTemplates.list(mockCtx, {}); + expectRequest('GET', '/v5/animation_templates'); expect(result).toHaveLength(1); }); - // Screenshots - it('invokes screenshots.list', async () => { - mockRequest.mockResolvedValue([{ uid: 'ss_1', status: 'completed' }]); - const result = await endpoints.screenshots.list(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/screenshots', - }), - ); - expect(result).toHaveLength(1); + it('invokes animationTemplates.get', async () => { + mockRequest.mockResolvedValue({ uid: 'at_1', name: 'AT 1' }); + const result = await endpoints.animationTemplates.get(mockCtx, { + uid: 'at_1', + }); + expectRequest('GET', '/v5/animation_templates/at_1'); + expect(result.uid).toBe('at_1'); }); - it('invokes screenshots.get', async () => { - mockRequest.mockResolvedValue({ uid: 'ss_1', status: 'completed' }); - const result = await endpoints.screenshots.get(mockCtx, { uid: 'ss_1' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/screenshots/ss_1', - }), - ); - expect(result.uid).toBe('ss_1'); + it('invokes animationTemplates.create', async () => { + mockRequest.mockResolvedValue({ uid: 'at_new', name: 'New AT' }); + const result = await endpoints.animationTemplates.create(mockCtx, { + name: 'New AT', + }); + expectRequest('POST', '/v5/animation_templates', { + body: { name: 'New AT' }, + }); + expect(result.uid).toBe('at_new'); }); - // Signed URLs - it('invokes signedUrls.getSignedBases', async () => { + it('invokes instantUrls.list', async () => { mockRequest.mockResolvedValue([ - { base_url: 'https://on-demand.bannerbear.com/taggedurl/xxx' }, + { uid: 'iu_1', base_url: 'https://on-demand.bannerbear.com/x' }, ]); - const result = await endpoints.signedUrls.getSignedBases(mockCtx, { - uid: 'tmpl_1', - }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/templates/tmpl_1/signed_bases', - }), - ); + const result = await endpoints.instantUrls.list(mockCtx, {}); + expectRequest('GET', '/v5/instant_urls'); expect(result).toHaveLength(1); }); - it('invokes signedUrls.createSignedBase', async () => { + it('invokes instantUrls.create', async () => { mockRequest.mockResolvedValue({ - base_url: 'https://on-demand.bannerbear.com/taggedurl/xxx', + uid: 'iu_new', + name: 'Share', + template: 'tmpl_1', + base_url: 'https://on-demand.bannerbear.com/x', }); - const result = await endpoints.signedUrls.createSignedBase(mockCtx, { - uid: 'tmpl_1', + const result = await endpoints.instantUrls.create(mockCtx, { + name: 'Share', + template: 'tmpl_1', + }); + expectRequest('POST', '/v5/instant_urls', { + body: { name: 'Share', template: 'tmpl_1' }, }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/templates/tmpl_1/signed_bases', - }), - ); expect(result.base_url).toBeDefined(); }); - // Webhooks API it('invokes webhooksApi.get', async () => { mockRequest.mockResolvedValue({ uid: 'wh_1', url: 'https://app.com/hook' }); const result = await endpoints.webhooksApi.get(mockCtx, { uid: 'wh_1' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/webhooks/wh_1', - }), - ); + expectRequest('GET', '/v5/webhooks/wh_1'); expect(result.uid).toBe('wh_1'); }); - it('invokes webhooksApi.create', async () => { + it('invokes webhooksApi.create with required name', async () => { mockRequest.mockResolvedValue({ uid: 'wh_new', + name: 'Image notifications', url: 'https://app.com/hook', + signing_key: 'whsec_abc', }); const result = await endpoints.webhooksApi.create(mockCtx, { + name: 'Image notifications', url: 'https://app.com/hook', - event: 'image.completed', + resource: 'image', + event: 'completed', + }); + expectRequest('POST', '/v5/webhooks', { + body: { + name: 'Image notifications', + url: 'https://app.com/hook', + resource: 'image', + event: 'completed', + }, }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/webhooks', - body: { url: 'https://app.com/hook', event: 'image.completed' }, - }), - ); expect(result.uid).toBe('wh_new'); }); it('invokes webhooksApi.delete', async () => { mockRequest.mockResolvedValue(undefined); const result = await endpoints.webhooksApi.delete(mockCtx, { uid: 'wh_1' }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'DELETE', - url: '/v5/webhooks/wh_1', - }), - ); + expectRequest('DELETE', '/v5/webhooks/wh_1'); expect(result).toEqual({ success: true }); }); - // Misc - it('invokes misc.getFonts', async () => { - mockRequest.mockResolvedValue([{ name: 'Roboto', family: 'sans-serif' }]); - const result = await endpoints.misc.getFonts(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/fonts', - }), - ); - expect(result).toHaveLength(1); - }); - - it('invokes misc.listEffects', async () => { - mockRequest.mockResolvedValue([{ name: 'Blur', slug: 'blur' }]); - const result = await endpoints.misc.listEffects(mockCtx, {}); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/effects', - }), - ); - expect(result).toHaveLength(1); - }); - it('invokes misc.joinPdfs', async () => { mockRequest.mockResolvedValue({ uid: 'pdf_1', @@ -633,35 +492,22 @@ describe('Bannerbear endpoint handler invocations', () => { joined_pdf_url: 'https://cdn.com/doc.pdf', }); const result = await endpoints.misc.joinPdfs(mockCtx, { - pdf_urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], + urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], + }); + expectRequest('POST', '/v5/tools/create_pdf', { + body: { + urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], + }, }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/pdfs', - body: { - pdf_urls: ['https://cdn.com/1.pdf', 'https://cdn.com/2.pdf'], - }, - }), - ); expect(result.joined_pdf_url).toBeDefined(); }); - // Workflows it('invokes workflows.listWorkflows', async () => { mockRequest.mockResolvedValue([{ uid: 'wf_1', name: 'Workflow 1' }]); const result = await endpoints.workflows.listWorkflows(mockCtx, { page: 1, }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/workflows', - query: { page: 1 }, - }), - ); + expectRequest('GET', '/v5/workflows', { query: { page: 1 } }); expect(result).toHaveLength(1); }); @@ -670,13 +516,7 @@ describe('Bannerbear endpoint handler invocations', () => { const result = await endpoints.workflows.getWorkflow(mockCtx, { uid: 'wf_1', }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/workflows/wf_1', - }), - ); + expectRequest('GET', '/v5/workflows/wf_1'); expect(result.name).toBe('Workflow 1'); }); @@ -690,14 +530,9 @@ describe('Bannerbear endpoint handler invocations', () => { workflow: 'wf_1', inputs: { title: 'Test' }, }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'POST', - url: '/v5/workflow_runs', - body: { workflow: 'wf_1', inputs: { title: 'Test' } }, - }), - ); + expectRequest('POST', '/v5/workflow_runs', { + body: { workflow: 'wf_1', inputs: { title: 'Test' } }, + }); expect(result.uid).toBe('wfr_1'); }); @@ -710,13 +545,7 @@ describe('Bannerbear endpoint handler invocations', () => { const result = await endpoints.workflows.getWorkflowRun(mockCtx, { uid: 'wfr_1', }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/workflow_runs/wfr_1', - }), - ); + expectRequest('GET', '/v5/workflow_runs/wfr_1'); expect(result.status).toBe('completed'); }); @@ -727,13 +556,7 @@ describe('Bannerbear endpoint handler invocations', () => { const result = await endpoints.workflows.listWorkflowRuns(mockCtx, { page: 1, }); - expect(mockRequest).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - method: 'GET', - url: '/v5/workflow_runs', - query: { page: 1 }, - }), - ); + expectRequest('GET', '/v5/workflow_runs', { query: { page: 1 } }); + expect(result).toHaveLength(1); }); }); diff --git a/packages/bannerbear/webhooks.test.ts b/packages/bannerbear/webhooks.test.ts index 6c602dfbe..2b1f5d6eb 100644 --- a/packages/bannerbear/webhooks.test.ts +++ b/packages/bannerbear/webhooks.test.ts @@ -3,8 +3,8 @@ import * as crypto from 'crypto'; import type { BannerbearContext } from './index'; import { bannerbear } from './index'; import { + createBannerbearAnimationCompletedMatch, createBannerbearImageCompletedMatch, - createBannerbearVideoCompletedMatch, verifyBannerbearWebhookSignature, } from './webhooks/types'; @@ -112,7 +112,7 @@ describe('Bannerbear webhooks verification and handlers', () => { const result = verifyBannerbearWebhookSignature(request, secret); expect(result.valid).toBe(false); expect(result.error).toBe( - 'Missing Authorization or x-bannerbear-signature header', + 'Missing Authorization or webhook signature header', ); }); @@ -149,18 +149,21 @@ describe('Bannerbear webhooks verification and handlers', () => { expect(res.statusCode).toBe(401); }); - it('processes videoCompleted webhook handler with valid signature', async () => { + it('processes animationCompleted webhook handler with valid signature', async () => { const webhookCtx = { ...mockCtx, key: secret }; const payload = { - uid: 'vid_123', + uid: 'an_123', status: 'completed' as const, - video_url: 'https://cdn.bannerbear.com/sample.mp4', + files: { mp4: 'https://cdn.bannerbear.com/sample.mp4' }, }; const req = createSignedWebhookRequest(payload) as unknown as Parameters< - typeof webhooks.video.videoCompleted.handler + typeof webhooks.animation.animationCompleted.handler >[1]; - const res = await webhooks.video.videoCompleted.handler(webhookCtx, req); + const res = await webhooks.animation.animationCompleted.handler( + webhookCtx, + req, + ); expect(res.success).toBe(true); expect(res.data).toEqual(payload); }); @@ -186,29 +189,29 @@ describe('Bannerbear webhooks verification and handlers', () => { }), }; - const videoPayload: RawWebhookRequest = { + const animationPayload: RawWebhookRequest = { headers: {}, body: JSON.stringify({ - uid: 'vid_123', + uid: 'an_123', status: 'completed', - video_url: 'https://cdn.bannerbear.com/sample.mp4', + files: { mp4: 'https://cdn.bannerbear.com/sample.mp4' }, }), }; expect(matcher(validImagePayload)).toBe(true); expect(matcher(pendingPayload)).toBe(false); - expect(matcher(videoPayload)).toBe(false); + expect(matcher(animationPayload)).toBe(false); }); - it('matches video completed webhook payloads', () => { - const matcher = createBannerbearVideoCompletedMatch(); + it('matches animation completed webhook payloads', () => { + const matcher = createBannerbearAnimationCompletedMatch(); - const validVideoPayload: RawWebhookRequest = { + const validAnimationPayload: RawWebhookRequest = { headers: {}, body: JSON.stringify({ - uid: 'vid_123', + uid: 'an_123', status: 'completed', - video_url: 'https://cdn.bannerbear.com/sample.mp4', + files: { mp4: 'https://cdn.bannerbear.com/sample.mp4' }, }), }; @@ -221,7 +224,16 @@ describe('Bannerbear webhooks verification and handlers', () => { }), }; - expect(matcher(validVideoPayload)).toBe(true); + expect(matcher(validAnimationPayload)).toBe(true); expect(matcher(imagePayload)).toBe(false); }); + + it('does not treat a generic uid/status body as Bannerbear', () => { + expect( + plugin.pluginWebhookMatcher?.({ + headers: {}, + body: JSON.stringify({ uid: 'other_1', status: 'completed' }), + }), + ).toBe(false); + }); });