diff --git a/package-lock.json b/package-lock.json index 8f39e46..c21870d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "speechmatics", - "version": "4.0.0-pre.0", + "version": "4.0.0-pre.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "speechmatics", - "version": "4.0.0-pre.0", + "version": "4.0.0-pre.1", "license": "MIT", "dependencies": { "bufferutil": "^4.0.7", diff --git a/package.json b/package.json index 0d5e082..4519c19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "speechmatics", - "version": "4.0.0-pre.0", + "version": "4.0.0-pre.1", "description": "Speechmatics Javascript Libraries", "types": "./dist/index.d.ts", "main": "./dist/index.js", diff --git a/src/batch/client.test.ts b/src/batch/client.test.ts index 2b776a4..b222489 100644 --- a/src/batch/client.test.ts +++ b/src/batch/client.test.ts @@ -1,12 +1,24 @@ import { BatchTranscription } from './'; -import { request } from '../utils/request'; +import { + SpeechmaticsConfigurationError, + SpeechmaticsNetworkError, + SpeechmaticsResponseError, +} from '../utils/errors'; -jest.mock('../utils/request'); -const mockedRequest = jest.mocked(request); +const originalFetch = global.fetch; +const mockedFetch: jest.Mock< + ReturnType, + Parameters +> = jest.fn(); +global.fetch = mockedFetch; describe('BatchTranscription', () => { afterEach(() => { - jest.clearAllMocks(); + mockedFetch.mockReset(); + }); + + afterAll(() => { + global.fetch = originalFetch; }); it('can be initialized with just an API key string', async () => { @@ -28,7 +40,9 @@ describe('BatchTranscription', () => { const apiKey = jest.fn(async () => 'asyncApiKey'); const batch = new BatchTranscription({ apiKey }); - mockedRequest.mockImplementation(async () => ({ jobs: [] })); + mockedFetch.mockImplementation( + async () => new Response(JSON.stringify({ jobs: [] }), { status: 200 }), + ); await batch.listJobs(); await batch.listJobs(); @@ -36,24 +50,87 @@ describe('BatchTranscription', () => { }); it('refreshes the API key on error and retries', async () => { - const keys = (function* () { - yield 'firstKey'; - yield 'secondKey'; - })(); - const apiKey = jest.fn(async () => keys.next().value as string); + const keys: IterableIterator = ['firstKey', 'secondKey'][ + Symbol.iterator + ](); + const apiKey: () => Promise = jest.fn( + async () => keys.next().value, + ); const batch = new BatchTranscription({ apiKey }); - mockedRequest.mockImplementation(async (apiKey: string) => { + mockedFetch.mockImplementation(async (_url, requestOpts) => { + const apiKey = JSON.parse( + JSON.stringify(requestOpts?.headers) ?? '{}', + ).Authorization?.split('Bearer ')[1]; + if (apiKey === 'firstKey') { - throw new Error('401 Unauthorized (mock)'); + return new Response( + '{"code": 401, "error": "Permission Denied", "mock": true}', + { status: 401 }, + ); } else { - return { jobs: [] }; + return new Response(JSON.stringify({ jobs: [] }), { status: 200 }); } }); const result = await batch.listJobs(); expect(apiKey).toBeCalledTimes(2); - expect(result.jobs).toBeInstanceOf(Array); + expect(Array.isArray(result.jobs)).toBe(true); + }); + + describe('Errors', () => { + it('throws a response error when the given API key is invalid', async () => { + mockedFetch.mockImplementation(async () => { + return new Response( + '{"code": 401, "error": "Permission Denied", "mock": true}', + { status: 401 }, + ); + }); + + const batch = new BatchTranscription({ apiKey: 'some-invalid-key' }); + const listJobs = batch.listJobs(); + await expect(listJobs).rejects.toBeInstanceOf(SpeechmaticsResponseError); + await expect(listJobs).rejects.toMatchInlineSnapshot( + '[SpeechmaticsResponseError: Permission Denied]', + ); + }); + + it('throws a configuration error if the apiKey is not present', async () => { + // @ts-expect-error + const batch = new BatchTranscription({}); + const listJobs = batch.listJobs(); + await expect(listJobs).rejects.toBeInstanceOf( + SpeechmaticsConfigurationError, + ); + await expect(listJobs).rejects.toMatchInlineSnapshot( + '[SpeechmaticsConfigurationError: Missing apiKey in configuration]', + ); + }); + + it('throws a network error if fetch fails', async () => { + mockedFetch.mockImplementationOnce(() => { + throw new TypeError('failed to fetch'); + }); + + const batch = new BatchTranscription({ apiKey: 'my-key' }); + const listJobs = batch.listJobs(); + await expect(listJobs).rejects.toBeInstanceOf(SpeechmaticsNetworkError); + await expect(listJobs).rejects.toMatchInlineSnapshot( + '[SpeechmaticsNetworkError: Error fetching from /v2/jobs]', + ); + }); + + it('throws an unexpected response error if an invalid response comes back', async () => { + mockedFetch.mockImplementationOnce(async () => { + return new Response('
502 bad gateway
'); + }); + + const batch = new BatchTranscription({ apiKey: 'my-key' }); + const listJobs = batch.listJobs(); + await expect(listJobs).rejects.toMatchInlineSnapshot( + '[SpeechmaticsInvalidTypeError: Failed to parse response JSON]', + ); + }); }); }); diff --git a/src/batch/client.ts b/src/batch/client.ts index 59cb965..a5ff9c9 100644 --- a/src/batch/client.ts +++ b/src/batch/client.ts @@ -13,6 +13,10 @@ import { QueryParams, request, SM_APP_PARAM_NAME } from '../utils/request'; import poll from '../utils/poll'; import RetrieveJobsFilters from '../types/list-job-filters'; import { BatchFeatureDiscovery } from '../types/batch-feature-discovery'; +import { + SpeechmaticsConfigurationError, + SpeechmaticsResponseError, +} from '../utils/errors'; export class BatchTranscription { private config: ConnectionConfigFull; @@ -37,23 +41,26 @@ export class BatchTranscription { this.config = new ConnectionConfigFull(config); } - private async refreshOnFail( + private async refreshOnAuthFail( doRequest: (key: string) => Promise, ): Promise { try { return await doRequest(this.apiKey ?? (await this.refreshApiKey())); } catch (e) { - console.info('Retrying due to error:', e); - return await doRequest(await this.refreshApiKey()); + if (e instanceof SpeechmaticsResponseError && e.response.code === 401) { + return await doRequest(await this.refreshApiKey()); + } else { + throw e; + } } } - private async get( + private async get( endpoint: string, contentType?: string, - queryParams?: QueryParams, + queryParams?: QueryParams, ): Promise { - return await this.refreshOnFail((key: string) => + return await this.refreshOnAuthFail((key: string) => request( key, this.config.batchUrl, @@ -71,7 +78,7 @@ export class BatchTranscription { body: FormData | null = null, contentType?: string, ): Promise { - return await this.refreshOnFail((key: string) => + return await this.refreshOnAuthFail((key: string) => request( key, this.config.batchUrl, @@ -84,11 +91,8 @@ export class BatchTranscription { ); } - private async delete( - endpoint: string, - params?: QueryParams, - ): Promise { - return this.refreshOnFail((key: string) => + private async delete(endpoint: string, params?: QueryParams): Promise { + return this.refreshOnAuthFail((key: string) => request(key, this.config.batchUrl, endpoint, 'DELETE', null, { ...params, [SM_APP_PARAM_NAME]: this.config.appId, @@ -109,7 +113,9 @@ export class BatchTranscription { format?: TranscriptionFormat, ): Promise { if (this.config.apiKey === undefined) - throw new Error('Error: apiKey is undefined'); + throw new SpeechmaticsConfigurationError( + 'Missing apiKey in configuration', + ); const submitResponse = await this.createTranscriptionJob(input, jobConfig); @@ -142,7 +148,9 @@ export class BatchTranscription { }, ): Promise { if (this.config.apiKey === undefined) - throw new Error('Error: apiKey is undefined'); + throw new SpeechmaticsConfigurationError( + 'Missing apiKey in configuration', + ); const config = { ...jobConfig, @@ -165,14 +173,26 @@ export class BatchTranscription { async listJobs( filters: RetrieveJobsFilters = {}, ): Promise { - return this.get('/v2/jobs', 'application/json', filters); + if (this.config.apiKey === undefined) + throw new SpeechmaticsConfigurationError( + 'Missing apiKey in configuration', + ); + return this.get('/v2/jobs', 'application/json', { ...filters }); } async getJob(id: string): Promise { + if (this.config.apiKey === undefined) + throw new SpeechmaticsConfigurationError( + 'Missing apiKey in configuration', + ); return this.get(`/v2/jobs/${id}`, 'application/json'); } async deleteJob(id: string, force = false): Promise { + if (this.config.apiKey === undefined) + throw new SpeechmaticsConfigurationError( + 'Missing apiKey in configuration', + ); const params = force ? { force } : undefined; return this.delete(`/v2/jobs/${id}`, params); } @@ -194,6 +214,10 @@ export class BatchTranscription { jobId: string, format: TranscriptionFormat = 'json-v2', ): Promise { + if (this.config.apiKey === undefined) + throw new SpeechmaticsConfigurationError( + 'Missing apiKey in configuration', + ); const params = { format: format === 'text' ? 'txt' : format }; const contentType = format === 'json-v2' ? 'application/json' : 'text/plain'; @@ -201,6 +225,10 @@ export class BatchTranscription { } async getDataFile(jobId: string) { + if (this.config.apiKey === undefined) + throw new SpeechmaticsConfigurationError( + 'Missing apiKey in configuration', + ); return this.get(`/v2/jobs/${jobId}/data`, 'application/json'); } diff --git a/src/index.ts b/src/index.ts index dd725a4..5f659ed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ export * from './client'; export * from './types'; export * from './realtime'; export * from './batch'; +export * from './utils/errors'; declare global { // This gets injected by ESBuild at compile time diff --git a/src/management-platform/auth.ts b/src/management-platform/auth.ts index 8ec35e3..13c0bb1 100644 --- a/src/management-platform/auth.ts +++ b/src/management-platform/auth.ts @@ -1,3 +1,4 @@ +import { SpeechmaticsNetworkError } from '../utils/errors'; import { request } from '../utils/request'; export default async function getShortLivedToken( @@ -23,7 +24,7 @@ export default async function getShortLivedToken( undefined, 'application/json', ).catch((err) => { - throw new Error(`Error fetching short lived token: ${err}`); + throw new SpeechmaticsNetworkError('Error fetching short lived token', err); }); return jsonResponse.key_value; } diff --git a/src/realtime/browser.ts b/src/realtime/browser.ts index 6d21daf..66678f5 100644 --- a/src/realtime/browser.ts +++ b/src/realtime/browser.ts @@ -6,6 +6,7 @@ import { } from '../utils/request'; import { ISocketWrapper } from '../types'; import { ModelError, RealtimeMessage } from '../types'; +import { SpeechmaticsUnsupportedEnvironment } from '../utils/errors'; /** * Wraps the socket api to be more useful in async/await kind of scenarios @@ -23,7 +24,9 @@ export class WebSocketWrapper implements ISocketWrapper { constructor() { if (typeof window === 'undefined') - throw new Error('window is undefined - are you running in a browser?'); + throw new SpeechmaticsUnsupportedEnvironment( + 'window is undefined - are you running in a browser?', + ); } async connect( diff --git a/src/realtime/client.ts b/src/realtime/client.ts index aab25d4..6e20dfc 100644 --- a/src/realtime/client.ts +++ b/src/realtime/client.ts @@ -17,6 +17,7 @@ import * as webWrapper from '../realtime/browser'; import * as chromeWrapper from '../realtime/extension'; import { EventMap } from '../types/event-map'; +import { SpeechmaticsUnsupportedEnvironment } from '../utils/errors'; /** * A class that represents a single realtime session. It's responsible for handling the connection and the messages. @@ -40,7 +41,7 @@ export class RealtimeSession { } else if (typeof process !== 'undefined') { socketImplementation = new nodeWrapper.NodeWebSocketWrapper(); } else { - throw new Error('Unsupported environment'); + throw new SpeechmaticsUnsupportedEnvironment(); } this.rtSocketHandler = new RealtimeSocketHandler( diff --git a/src/realtime/extension.ts b/src/realtime/extension.ts index 3464b61..eb1734b 100644 --- a/src/realtime/extension.ts +++ b/src/realtime/extension.ts @@ -6,6 +6,7 @@ import { } from '../utils/request'; import { ISocketWrapper } from '../types'; import { ModelError, RealtimeMessage } from '../types'; +import { SpeechmaticsUnsupportedEnvironment } from '../utils/errors'; /** * Wraps the socket api to be more useful in async/await kind of scenarios @@ -23,7 +24,7 @@ export class WebSocketWrapper implements ISocketWrapper { constructor() { if (typeof chrome.runtime === 'undefined') - throw new Error( + throw new SpeechmaticsUnsupportedEnvironment( 'chrome is undefined - are you running in a background script?', ); } diff --git a/src/realtime/handlers.ts b/src/realtime/handlers.ts index 65be912..4c2035b 100755 --- a/src/realtime/handlers.ts +++ b/src/realtime/handlers.ts @@ -18,6 +18,7 @@ import { ISocketWrapper, SessionConfig, } from '../types'; +import { SpeechmaticsUnexpectedResponse } from '../utils/errors'; export const defaultLanguage = 'en'; @@ -156,8 +157,21 @@ export class RealtimeSocketHandler { this.sub?.onInfo?.(data as Info); break; + // We don't expect these messages to be sent (only received) + case MessagesEnum.StartRecognition: + case MessagesEnum.AddAudio: + case MessagesEnum.EndOfStream: + case MessagesEnum.SetRecognitionConfig: + // We also don't expect undefined + case undefined: + throw new SpeechmaticsUnexpectedResponse( + `Unexpected RealtimeMessage during onSocketMessage: ${data.message}`, + ); default: - throw new Error('Unexpected message'); + data.message satisfies never; + throw new SpeechmaticsUnexpectedResponse( + `Unexpected RealtimeMessage during onSocketMessage: ${data.message}`, + ); } }; diff --git a/src/realtime/node.ts b/src/realtime/node.ts index 977222c..aeba8ba 100644 --- a/src/realtime/node.ts +++ b/src/realtime/node.ts @@ -8,6 +8,7 @@ import { SM_SDK_PARAM_NAME, getSmSDKVersion, } from '../utils/request'; +import { SpeechmaticsUnsupportedEnvironment } from '../utils/errors'; /** * Wraps the socket api to be more useful in async/await kind of scenarios @@ -24,7 +25,9 @@ export class NodeWebSocketWrapper implements ISocketWrapper { constructor() { if (typeof process === 'undefined') - throw new Error('process is undefined - are you running in node?'); + throw new SpeechmaticsUnsupportedEnvironment( + 'process is undefined - are you running in node?', + ); } async connect( diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..18503c5 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,99 @@ +import z from 'zod'; + +import { ErrorResponse, ErrorResponseErrorEnum } from '../types'; + +const ErrorResponseSchema: z.ZodType = z.object({ + code: z.number(), + detail: z.optional(z.string()), + error: z.nativeEnum(ErrorResponseErrorEnum), +}); + +type SpeechamticsErrorEnum = InternalErrorEnum | ErrorResponseErrorEnum; +interface SpeechmaticsErrorInterface extends Error { + error: SpeechamticsErrorEnum; +} + +export class SpeechmaticsResponseError + extends Error + implements SpeechmaticsErrorInterface +{ + response: ErrorResponse; + error: ErrorResponseErrorEnum; + + constructor(errorResponse: ErrorResponse | unknown) { + const parse = ErrorResponseSchema.safeParse(errorResponse); + if (parse.success) { + super(parse.data.error); + this.response = parse.data; + this.error = parse.data.error; + this.name = 'SpeechmaticsResponseError'; + } else { + throw new SpeechmaticsUnexpectedResponse(undefined, errorResponse); + } + } +} + +export const InternalErrorEnum = { + ConfigurationError: 'Configuration error', + NetworkError: 'Network error', + UnsupportedEnvironment: 'Unsupported environment', + UnexpectedMessage: 'Unexpected message', + UnexpectedResponse: 'Unexpected response', + InvalidTypeError: 'Invalid type error', +} as const; + +export type InternalErrorEnum = + typeof InternalErrorEnum[keyof typeof InternalErrorEnum]; + +class SpeechmaticsInternalError extends Error { + error: InternalErrorEnum; + cause?: unknown; // e.g. a caught error or response + + constructor(error: InternalErrorEnum, message?: string, cause?: unknown) { + super(message ?? error); + this.name = 'SpeechmaticsInternalError'; + this.error = error; + this.cause = cause; + } +} + +export class SpeechmaticsConfigurationError extends SpeechmaticsInternalError { + constructor(message?: string) { + super(InternalErrorEnum.ConfigurationError, message); + this.name = 'SpeechmaticsConfigurationError'; + } +} +export class SpeechmaticsNetworkError extends SpeechmaticsInternalError { + constructor(message?: string, cause?: unknown) { + super(InternalErrorEnum.NetworkError, message, cause); + this.name = 'SpeechmaticsNetworkError'; + } +} +export class SpeechmaticsUnsupportedEnvironment extends SpeechmaticsInternalError { + constructor(message?: string) { + super(InternalErrorEnum.UnsupportedEnvironment, message); + this.name = 'SpeechmaticsUnsupportedEnvironment'; + } +} +export class SpeechmaticsUnexpectedMessage extends SpeechmaticsInternalError { + constructor(message?: string) { + super(InternalErrorEnum.UnexpectedMessage, message); + this.name = 'SpeechmaticsUnexpectedMessage'; + } +} +export class SpeechmaticsUnexpectedResponse extends SpeechmaticsInternalError { + constructor(message?: string, response?: unknown) { + super(InternalErrorEnum.UnexpectedResponse, message, response); + this.name = 'SpeechmaticsUnexpectedResponse'; + } +} +export class SpeechmaticsInvalidTypeError extends SpeechmaticsInternalError { + constructor(message?: string, cause?: unknown) { + super(InternalErrorEnum.InvalidTypeError, message, cause); + this.name = 'SpeechmaticsInvalidTypeError'; + } +} + +export type SpeechmaticsError = + | SpeechmaticsInternalError + | SpeechmaticsResponseError; diff --git a/src/utils/file-utils.ts b/src/utils/file-utils.ts deleted file mode 100644 index f471d1e..0000000 --- a/src/utils/file-utils.ts +++ /dev/null @@ -1,20 +0,0 @@ -export async function determineFileBuffer( - urlOrBuffer: string | Buffer, -): Promise { - if (typeof urlOrBuffer === 'string') { - return await fetchFileBuffer(urlOrBuffer); - } else if (urlOrBuffer instanceof Buffer) { - return urlOrBuffer as Buffer; - } - - return Promise.reject('SMjs error: Invalid file'); -} - -export async function fetchFileBuffer(url: string): Promise { - const response = await fetch(url); - if (response?.ok) { - return Buffer.from(await response.arrayBuffer()); - } else { - return Promise.reject(`SMjs error: ${response.statusText}`); - } -} diff --git a/src/utils/request.ts b/src/utils/request.ts index 773bac2..2754d0a 100644 --- a/src/utils/request.ts +++ b/src/utils/request.ts @@ -1,16 +1,22 @@ +import { + SpeechmaticsInvalidTypeError, + SpeechmaticsNetworkError, + SpeechmaticsResponseError, +} from './errors'; + export type HttpMethod = 'GET' | 'PUT' | 'POST' | 'DELETE'; -export type QueryParams = Partial< - Record +export type QueryParams = Readonly< + Record >; -export async function request( +export async function request( apiKey: string, url: string, path: string, method: HttpMethod = 'POST', payload?: BodyInit | null | undefined, - params?: QueryParams, + params?: QueryParams, contentType?: string, ): Promise { const requestOptions: RequestInit = { @@ -29,27 +35,40 @@ export async function request( fullUrl = addQueryParamsToUrl(fullUrl, params); } - const response = await fetch(fullUrl, requestOptions); + let response: Response; + try { + response = await fetch(fullUrl, requestOptions); + } catch (err) { + throw new SpeechmaticsNetworkError(`Error fetching from ${path}`, err); + } if (!response.ok) { - throw new Error( - `SMjs error: ${response.statusText} ${ - response.status - } ${await response.text()}`, - ); + const responseJson = await response.json(); + throw new SpeechmaticsResponseError(responseJson); } const isPlain = contentType === 'text/plain'; let result: T; - try { - if (isPlain) { + + if (isPlain) { + try { result = (await response.text()) as T; - } else { + } catch (err) { + throw new SpeechmaticsInvalidTypeError( + 'Failed to parse response text', + err, + ); + } + } else { + try { result = (await response.json()) as T; + } catch (err) { + throw new SpeechmaticsInvalidTypeError( + 'Failed to parse response JSON', + err, + ); } - } catch (error) { - throw new Error(`SMjs error, can't parse json: ${error}`); } return result; @@ -62,14 +81,14 @@ export function getSmSDKVersion(): string { return `js-${SDK_VERSION}`; } -export function addQueryParamsToUrl( +export function addQueryParamsToUrl( url: string, - queryParams: QueryParams, + queryParams: QueryParams, ): string { const parsedUrl = new URL(url); const params = new URLSearchParams(parsedUrl.search); Object.keys(queryParams).forEach((key) => { - const value = queryParams[key as K]; + const value = queryParams[key]; if (value !== undefined) params.append(key, `${value}`); }); parsedUrl.search = params.toString(); diff --git a/tests/index.test.ts b/tests/index.test.ts index 0e5f0c3..951fabe 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -3,7 +3,7 @@ import { AddTranscript, RetrieveTranscriptResponse, } from '../dist'; -import { Speechmatics } from '../dist'; +import { Speechmatics, SpeechmaticsResponseError } from '../dist'; import dotenv from 'dotenv'; import fs from 'fs'; import path from 'path'; @@ -55,7 +55,10 @@ describe('Testing batch capabilities', () => { jobResult = await speechmatics.batch.getJobResult(id, 'text'); return true; } catch (err) { - if (err instanceof Error && err.toString().includes('404')) { + if ( + err instanceof SpeechmaticsResponseError && + err.response.code === 404 + ) { return false; } else { throw err;