Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/castingwords/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# CastingWords

Corsair integration for the CastingWords Store API v4.

## Authentication

CastingWords uses an API key. Configure it through Corsair's `api_key` credential store or pass the key through plugin options for local development.

## API surface

The plugin exposes the nine operations in the CastingWords v4 surface:

- `createOrder.create` — create a transcription order from a media URL
- `prepayBalance.get` — read the prepaid balance
- `audiofileDetails.get` — read audiofile details and state
- `transcript.get` — retrieve a transcript in a supported format
- `upgrade.create` — order audiofile upgrades
- `refund.create` — refund an eligible audiofile
- `invoice.get` — read invoice details
- `webhook.get` — read the registered webhook URL
- `webhook.set` — set the registered webhook URL

Provider documentation: https://castingwords.com/docs/developer/SimpleAPI.html
59 changes: 59 additions & 0 deletions packages/castingwords/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { request } from 'corsair/http';
import {
CASTINGWORDS_API_BASE,
makeCastingwordsRequest,
toFormBody,
} from './client';

jest.mock('corsair/http', () => ({
ApiError: class ApiError extends Error {},
request: jest.fn(),
}));

const requestMock = request as unknown as jest.Mock;

describe('CastingWords client', () => {
beforeEach(() => requestMock.mockReset());

it('uses the documented API v4 base URL', () => {
expect(CASTINGWORDS_API_BASE).toBe('https://castingwords.com/store/API4');
});

it('encodes repeatable form fields', () => {
expect(
toFormBody({ api_key: 'key', sku: ['TRANS14', 'TSTMP1'], test: '1' }),
).toBe('api_key=key&sku=TRANS14&sku=TSTMP1&test=1');
});

it('adds the API key to GET query parameters', async () => {
requestMock.mockResolvedValue({ balance: 10 });
await makeCastingwordsRequest('prepay_balance', 'secret');
expect(requestMock).toHaveBeenCalledWith(
expect.objectContaining({ BASE: CASTINGWORDS_API_BASE }),
expect.objectContaining({ method: 'GET', query: { api_key: 'secret' } }),
);
});

it('sends POST fields as URL-encoded data', async () => {
requestMock.mockResolvedValue({ message: 'ok' });
await makeCastingwordsRequest('order_url', 'secret', {
method: 'POST',
form: { url: 'https://example.com/a.mp3', sku: ['TRANS14', 'TSTMP1'] },
});
expect(requestMock).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
method: 'POST',
body: 'api_key=secret&url=https%3A%2F%2Fexample.com%2Fa.mp3&sku=TRANS14&sku=TSTMP1',
mediaType: 'application/x-www-form-urlencoded',
}),
);
});

it('preserves provider errors as CastingwordsAPIError', async () => {
requestMock.mockRejectedValue(new Error('provider failed'));
await expect(
makeCastingwordsRequest('prepay_balance', 'secret'),
).rejects.toThrow('provider failed');
});
});
83 changes: 83 additions & 0 deletions packages/castingwords/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';

export class CastingwordsAPIError extends Error {
public readonly status?: number;
public readonly body?: unknown;
public readonly retryAfter?: number;

constructor(message: string, options?: { cause?: Error }) {
super(message, options?.cause ? { cause: options.cause } : undefined);
this.name = 'CastingwordsAPIError';
if (options?.cause instanceof ApiError) {
this.status = options.cause.status;
this.body = options.cause.body;
this.retryAfter = options.cause.retryAfter;
}
}
}

export const CASTINGWORDS_API_BASE = 'https://castingwords.com/store/API4';

type FormValue = string | number | boolean | string[] | undefined;

type RequestOptions = {
method?: 'GET' | 'POST';
query?: Record<string, string | number | boolean | undefined>;
form?: Record<string, FormValue>;
};

export function toFormBody(fields: Record<string, FormValue>): string {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(fields)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
for (const item of value) params.append(key, item);
continue;
}
params.append(key, String(value));
}
return params.toString();
}

export async function makeCastingwordsRequest<T>(
endpoint: string,
apiKey: string,
options: RequestOptions = {},
): Promise<T> {
const method = options.method ?? 'GET';
const config: OpenAPIConfig = {
BASE: CASTINGWORDS_API_BASE,
VERSION: '4.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: undefined,
HEADERS: {
Accept: 'application/json',
},
};

const formBody = options.form
? toFormBody({ api_key: apiKey, ...options.form })
: undefined;

const requestOptions: ApiRequestOptions = {
method,
url: endpoint,
query: method === 'GET' ? { api_key: apiKey, ...options.query } : undefined,
body: formBody,
mediaType: formBody ? 'application/x-www-form-urlencoded' : undefined,
};

try {
return await request<T>(config, requestOptions);
} catch (error) {
if (error instanceof ApiError) {
throw new CastingwordsAPIError(error.message, { cause: error });
}
if (error instanceof Error) {
throw new CastingwordsAPIError(error.message, { cause: error });
}
throw new CastingwordsAPIError('Unknown CastingWords API error');
}
}
96 changes: 96 additions & 0 deletions packages/castingwords/endpoints.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { request } from 'corsair/http';
import {
createOrder,
getAudiofileDetails,
getInvoice,
getPrepayBalance,
getTranscript,
getWebhook,
orderUpgrade,
refundAudiofile,
setWebhook,
} from './endpoints';

jest.mock('corsair/core', () => ({
logEventFromContext: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('corsair/http', () => ({
ApiError: class ApiError extends Error {},
request: jest.fn(),
}));

const requestMock = request as unknown as jest.Mock;
const ctx = { key: 'test-key' } as never;

describe('CastingWords endpoints', () => {
beforeEach(() => requestMock.mockReset());

it('creates an order', async () => {
requestMock.mockResolvedValue({
audiofiles: [101],
order: 'order-1',
message: 'ok',
});
await expect(
createOrder(ctx, { url: 'https://example.com/a.mp3', sku: ['TRANS14'] }),
).resolves.toMatchObject({ order: 'order-1' });
});

it('gets prepaid balance', async () => {
requestMock.mockResolvedValue({ balance: 4.5 });
await expect(getPrepayBalance(ctx, {})).resolves.toEqual({ balance: 4.5 });
});

it('gets audiofile details', async () => {
requestMock.mockResolvedValue({
audiofile: { id: 101, statename: 'Delivered' },
});
await expect(
getAudiofileDetails(ctx, { audiofileId: 101 }),
).resolves.toMatchObject({ audiofile: { statename: 'Delivered' } });
});

it('gets a transcript', async () => {
requestMock.mockResolvedValue('transcript text');
await expect(
getTranscript(ctx, { audiofileId: 101, extension: 'txt' }),
).resolves.toBe('transcript text');
});

it('orders an upgrade', async () => {
requestMock.mockResolvedValue({ message: 'success' });
await expect(
orderUpgrade(ctx, { audiofileId: 101, sku: ['TSTMP1'] }),
).resolves.toMatchObject({ message: 'success' });
});

it('refunds an audiofile', async () => {
requestMock.mockResolvedValue({ message: 'success' });
await expect(
refundAudiofile(ctx, { audiofileId: 101 }),
).resolves.toMatchObject({ message: 'success' });
});

it('gets an invoice', async () => {
requestMock.mockResolvedValue({ id: 55, state: 'PAID', items: [] });
await expect(getInvoice(ctx, { invoiceId: 55 })).resolves.toMatchObject({
id: 55,
state: 'PAID',
});
});

it('gets the registered webhook', async () => {
requestMock.mockResolvedValue({ webhook: 'https://example.com/hook' });
await expect(getWebhook(ctx, {})).resolves.toEqual({
webhook: 'https://example.com/hook',
});
});

it('sets the registered webhook', async () => {
requestMock.mockResolvedValue({ webhook: 'https://example.com/hook' });
await expect(
setWebhook(ctx, { webhook: 'https://example.com/hook' }),
).resolves.toEqual({ webhook: 'https://example.com/hook' });
});
});
32 changes: 32 additions & 0 deletions packages/castingwords/endpoints/create-order/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { logEventFromContext } from 'corsair/core';
import { makeCastingwordsRequest } from '../../client';
import type { CastingwordsEndpoints } from '..';
import { CastingwordsEndpointOutputSchemas } from '../types';

export const createOrder: CastingwordsEndpoints['createOrder'] = async (
ctx,
input,
) => {
const response = await makeCastingwordsRequest<unknown>(
'order_url',
ctx.key,
{
method: 'POST',
form: {
url: input.url,
sku: input.sku,
test: input.test ? '1' : undefined,
notes: input.notes,
name: input.names,
},
},
);
const parsed = CastingwordsEndpointOutputSchemas.createOrder.parse(response);
await logEventFromContext(
ctx,
'castingwords.create_order',
{ url: input.url },
'completed',
);
return parsed;
};
21 changes: 21 additions & 0 deletions packages/castingwords/endpoints/get-audiofile-details/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { logEventFromContext } from 'corsair/core';
import { makeCastingwordsRequest } from '../../client';
import type { CastingwordsEndpoints } from '..';
import { CastingwordsEndpointOutputSchemas } from '../types';

export const getAudiofileDetails: CastingwordsEndpoints['getAudiofileDetails'] =
async (ctx, input) => {
const response = await makeCastingwordsRequest<unknown>(
`audiofile/${encodeURIComponent(String(input.audiofileId))}`,
ctx.key,
);
const parsed =
CastingwordsEndpointOutputSchemas.getAudiofileDetails.parse(response);
await logEventFromContext(
ctx,
'castingwords.get_audiofile_details',
{ audiofileId: input.audiofileId },
'completed',
);
return parsed;
};
22 changes: 22 additions & 0 deletions packages/castingwords/endpoints/get-invoice/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { logEventFromContext } from 'corsair/core';
import { makeCastingwordsRequest } from '../../client';
import type { CastingwordsEndpoints } from '..';
import { CastingwordsEndpointOutputSchemas } from '../types';

export const getInvoice: CastingwordsEndpoints['getInvoice'] = async (
ctx,
input,
) => {
const response = await makeCastingwordsRequest<unknown>(
`invoice/${encodeURIComponent(String(input.invoiceId))}`,
ctx.key,
);
const parsed = CastingwordsEndpointOutputSchemas.getInvoice.parse(response);
await logEventFromContext(
ctx,
'castingwords.get_invoice',
{ invoiceId: input.invoiceId },
'completed',
);
return parsed;
};
24 changes: 24 additions & 0 deletions packages/castingwords/endpoints/get-transcript/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { logEventFromContext } from 'corsair/core';
import { makeCastingwordsRequest } from '../../client';
import type { CastingwordsEndpoints } from '..';
import { CastingwordsEndpointOutputSchemas } from '../types';

export const getTranscript: CastingwordsEndpoints['getTranscript'] = async (
ctx,
input,
) => {
const response = await makeCastingwordsRequest<unknown>(
`audiofile/${encodeURIComponent(String(input.audiofileId))}/transcript.${input.extension}`,
ctx.key,
{ query: { test: input.test ? '1' : undefined } },
);
const parsed =
CastingwordsEndpointOutputSchemas.getTranscript.parse(response);
await logEventFromContext(
ctx,
'castingwords.get_transcript',
{ audiofileId: input.audiofileId, extension: input.extension },
'completed',
);
return parsed;
};
13 changes: 13 additions & 0 deletions packages/castingwords/endpoints/get-webhook/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { logEventFromContext } from 'corsair/core';
import { makeCastingwordsRequest } from '../../client';
import type { CastingwordsEndpoints } from '..';
import { CastingwordsEndpointOutputSchemas } from '../types';

export const getWebhook: CastingwordsEndpoints['getWebhook'] = async (ctx) => {
const response = await makeCastingwordsRequest<unknown>('webhook', ctx.key);
const parsed = CastingwordsEndpointOutputSchemas.getWebhook.parse(
typeof response === 'string' ? { webhook: response } : response,
);
await logEventFromContext(ctx, 'castingwords.get_webhook', {}, 'completed');
return parsed;
};
Loading
Loading