Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Export Speech-to-Text Endpoint #806

Merged
merged 8 commits into from
Sep 22, 2024
Merged
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
1 change: 1 addition & 0 deletions __mocks__/@sendgrid/mail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export default {};
3 changes: 3 additions & 0 deletions __mocks__/axios.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export const mockRequest = jest.fn((config) => config);
export const request = jest.fn();
// @ts-expect-error
mockRequest.request = request;

export default mockRequest;
3 changes: 3 additions & 0 deletions __mocks__/shelljs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default {
mkdir: jest.fn(),
};
4 changes: 4 additions & 0 deletions jest.backend.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ export default {
],
testTimeout: 20000,
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>'],
moduleFileExtensions: ['ts', 'js', 'json'],
moduleNameMapper: {
'src/(.*)': '<rootDir>/src/$1',
},
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
Expand Down
41 changes: 40 additions & 1 deletion src/__tests__/shared/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import LanguageEnum from '../../shared/constants/LanguageEnum';
import { SuggestionSourceEnum } from '../../shared/constants/SuggestionSourceEnum';
import WordClass from '../../shared/constants/WordClass';
import { Definition, IncomingExample, IncomingWord } from '../../types';
import {
Definition,
IncomingExample,
IncomingWord,
DeveloperDocument,
DeveloperUsage,
} from '../../types';
import { Types } from 'mongoose';
import AccountStatus from '../../shared/constants/AccountStatus';
import ApiType from '../../shared/constants/ApiType';
import Plan from '../../shared/constants/Plan';

export const documentId = new Types.ObjectId('569ed8269353e9f4c51617aa');

export const wordFixture = (wordData: Partial<IncomingWord>) => ({
definitions: [],
Expand Down Expand Up @@ -55,3 +67,30 @@ export const exampleFixture = (exampleData: Partial<IncomingExample>) => ({
origin: SuggestionSourceEnum.INTERNAL,
...exampleData,
});

export const developerFixture = (developerData: Partial<DeveloperDocument>) => ({
name: '',
id: '',
apiKey: '',
email: '',
password: '',
usage: {
date: new Date(),
count: 0,
},
firebaseId: '',
stripeId: '',
plan: Plan.STARTER,
accountStatus: AccountStatus.UNPAID,
...developerData,
});

export const developerUsageFixture = (developerFixture: Partial<DeveloperUsage>) => ({
developerId: '',
usageType: ApiType.DICTIONARY,
usage: {
date: new Date(),
count: 0,
},
...developerFixture,
});
11 changes: 8 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { defineBoolean, defineInt, defineString } from 'firebase-functions/params';
import './shared/utils/wrapConsole';

const Environment = {
export const Environment = {
BUILD: 'build',
PRODUCTION: 'production',
DEVELOPMENT: 'development',
Expand Down Expand Up @@ -45,7 +45,6 @@ const STRIPE_SECRET_KEY_SOURCE = defineString('STRIPE_SECRET_KEY').value();
const STRIPE_ENDPOINT_SECRET_SOURCE = defineString('STRIPE_ENDPOINT_SECRET').value();

const dotenv = process.env.NODE_ENV !== Environment.BUILD ? require('dotenv') : null;
const sgMail = process.env.NODE_ENV !== Environment.BUILD ? require('@sendgrid/mail') : null;

if (dotenv) {
dotenv.config();
Expand Down Expand Up @@ -102,13 +101,19 @@ export const CORS_CONFIG = {
export const API_ROUTE = isProduction ? '' : `http://localhost:${PORT}`;
export const API_DOCS = 'https://docs.igboapi.com';

// IgboSpeech
export const SPEECH_TO_TEXT_API = isProduction
? 'https://speech.igboapi.com'
: 'http://localhost:3333';

// SendGrid API
export const SENDGRID_API_KEY = SENDGRID_API_KEY_SOURCE || '';
export const SENDGRID_NEW_DEVELOPER_ACCOUNT_TEMPLATE =
SENDGRID_NEW_DEVELOPER_ACCOUNT_TEMPLATE_SOURCE;
export const API_FROM_EMAIL = '[email protected]';

if (sgMail && !isTest) {
if (process.env.NODE_ENV !== Environment.BUILD && !isTest) {
const sgMail = require('@sendgrid/mail'); // eslint-disable-line
sgMail.setApiKey(SENDGRID_API_KEY);
}

Expand Down
123 changes: 123 additions & 0 deletions src/controllers/__tests__/speechToText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import axios from 'axios';
import {
requestFixture,
responseFixture,
nextFunctionFixture,
} from '../../../__tests__/shared/fixtures';
import { getTranscription } from '../speechToText';
import { fetchBase64Data } from '../utils/fetchBase64Data';

jest.mock('axios');
jest.mock('../utils/fetchBase64Data');

describe('speechToText', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('calls audio IgboSpeech audio endpoint to upload audio with url', async () => {
const req = requestFixture({ body: { audioUrl: 'https://igboapi.com' } });
const res = responseFixture();
const next = nextFunctionFixture();
const base64 = 'base64';
// @ts-expect-error
fetchBase64Data.mockResolvedValue(base64);
jest.spyOn(axios, 'request').mockResolvedValue({
data: { audioId: 'audioId', audioUrl: 'https://igboapi.com' },
});
await getTranscription(req, res, next);
// @ts-expect-error
expect(axios.request.mock.calls[0][0]).toMatchObject({
method: 'POST',
url: 'http://localhost:3333/audio',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'main_key',
},
data: { base64 },
});
// @ts-expect-error
expect(axios.request.mock.calls[1][0]).toMatchObject({
method: 'POST',
url: 'http://localhost:3333/predict',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'main_key',
},
data: { id: 'audioId', url: 'https://igboapi.com' },
});
});

it('calls audio IgboSpeech audio endpoint to upload audio with base64', async () => {
const base64 = 'data:audio';
const req = requestFixture({ body: { audioUrl: base64 } });
const res = responseFixture();
const next = nextFunctionFixture();
// @ts-expect-error
fetchBase64Data.mockResolvedValue(base64);
jest.spyOn(axios, 'request').mockResolvedValue({
data: { audioId: 'audioId', audioUrl: 'https://igboapi.com' },
});
await getTranscription(req, res, next);
// @ts-expect-error
expect(axios.request.mock.calls[0][0]).toMatchObject({
method: 'POST',
url: 'http://localhost:3333/audio',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'main_key',
},
data: { base64 },
});
// @ts-expect-error
expect(axios.request.mock.calls[1][0]).toMatchObject({
method: 'POST',
url: 'http://localhost:3333/predict',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'main_key',
},
data: { id: 'audioId', url: 'https://igboapi.com' },
});
});

it('does not call audio IgboSpeech audio endpoint to upload audio', async () => {
jest.resetAllMocks();
const audioUrl = 'https://igbo-api.s3.us-east-2.com/audio-pronunciations/audioId.mp3';
const req = requestFixture({
body: { audioUrl },
});
const res = responseFixture();
const next = nextFunctionFixture();
const base64 = 'base64';
// @ts-expect-error
fetchBase64Data.mockResolvedValue(base64);
jest.spyOn(axios, 'request').mockResolvedValue({});
await getTranscription(req, res, next);
expect(axios.request).not.toHaveBeenCalledWith({
method: 'POST',
url: 'http://localhost:3333/audio',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'main_key',
},
data: { base64 },
});
expect(axios.request).toHaveBeenCalledWith({
method: 'POST',
url: 'http://localhost:3333/predict',
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'main_key',
},
data: { id: 'audioId', url: audioUrl },
});
});

it('throws error due to non-public audio url', () => {
const req = requestFixture({ body: { audioUrl: 'audioUrl' } });
const res = responseFixture();
const next = nextFunctionFixture();
getTranscription(req, res, next);
expect(next).toHaveBeenCalled();
});
});
20 changes: 12 additions & 8 deletions src/controllers/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,9 @@
SENDGRID_API_KEY,
isTest,
isProduction,
Environment,
} from '../config';

const sgMail = (process.env.NODE_ENV as string) !== 'build' ? require('@sendgrid/mail') : {};

if (sgMail && sgMail.setApiKey && !isTest) {
sgMail.setApiKey(SENDGRID_API_KEY);
}

type EmailTemplate = {
to: string[],
templateId: string,
Expand All @@ -29,18 +24,26 @@
});

/* Wrapper around SendGrid function to handle errors */
export const sendEmail = (message: MailDataRequired) =>
!isTest
export const sendEmail = (message: MailDataRequired) => {
const sgMail =
// eslint-disable-next-line global-require
(process.env.NODE_ENV as string) !== Environment.BUILD ? require('@sendgrid/mail') : {};

if (!isTest) {
sgMail.setApiKey(SENDGRID_API_KEY);
}

return !isTest
? sgMail
.send(message)
.then(() => {
if (!isProduction) {
console.green('Email successfully sent.');

Check warning on line 41 in src/controllers/email.ts

View workflow job for this annotation

GitHub Actions / Run linters

Unexpected console statement
}
})
.catch((err: any) => {

Check warning on line 44 in src/controllers/email.ts

View workflow job for this annotation

GitHub Actions / Run linters

Unexpected any. Specify a different type
if (!isProduction) {
console.red(err);

Check warning on line 46 in src/controllers/email.ts

View workflow job for this annotation

GitHub Actions / Run linters

Unexpected console statement
return Promise.resolve(err);
}
throw err;
Expand All @@ -51,6 +54,7 @@
}
return Promise.resolve();
})();
};

type DeveloperEmailConfig = {
apiKey: string,
Expand Down
71 changes: 71 additions & 0 deletions src/controllers/speechToText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import axios from 'axios';
import { MiddleWare } from '../types';
import { fetchBase64Data } from './utils/fetchBase64Data';
import { parseAWSIdFromUri } from './utils/parseAWS';
import { MAIN_KEY, SPEECH_TO_TEXT_API } from '../config';

interface AudioMetadata {
audioId: string;
audioUrl: string;
}

interface Prediction {
transcription: string;
}

/**
* Talks to Speech-to-Text model to transcribe provided audio URL
* @param req
* @param res
* @param next
* @returns Audio transcription
*/
export const getTranscription: MiddleWare = async (req, res, next) => {
try {
const { audioUrl: audio } = req.body;
if (!audio.startsWith('https://') && !audio.startsWith('data:audio')) {
throw new Error('Audio URL must either be hosted publicly or a valid base64.');
}

let payload = { id: '', url: '' };
const base64 = audio.startsWith('https://') ? await fetchBase64Data(audio) : audio;

// If the audio doesn't come from Igbo API S3, we will pass into IgboSpeech
if (!audio.includes('igbo-api.s3.us-east-2')) {
const { data: response } = await axios
.request<AudioMetadata>({
method: 'POST',
url: `${SPEECH_TO_TEXT_API}/audio`,
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAIN_KEY,
},
data: { base64 },
})
.catch((err) => {
console.log('Error requesting /audio', err);
return { data: { audioId: '', audioUrl: '' } };
});

payload = { id: response.audioId, url: response.audioUrl };
} else {
const audioId = parseAWSIdFromUri(audio);
payload = { id: audioId, url: audio };
}

// Talks to prediction endpoint
const { data: response } = await axios.request<Prediction>({
method: 'POST',
url: `${SPEECH_TO_TEXT_API}/predict`,
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAIN_KEY,
},
data: payload,
});

return res.send({ transcription: response.transcription });
} catch (err) {
return next();
}
};
1 change: 1 addition & 0 deletions src/controllers/utils/__mocks__/fetchBase64Data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const fetchBase64Data = jest.fn(() => '');
30 changes: 30 additions & 0 deletions src/controllers/utils/__tests__/parseAWS.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { parseAWSIdFromKey, parseAWSIdFromUri } from '../parseAWS';

describe('parseAWS', () => {
it('parses out audio Id from AWS Key', () => {
const res = parseAWSIdFromKey('audio-pronunciations/audioId.mp3');
expect(res).toEqual('audioId');
});

it('does not parse out audio Id correctly for AWS Key', () => {
const res = parseAWSIdFromKey(
'https://igbo-api.s3.us-east-2.com/audio-pronunciations/audioId.mp3'
);
expect(res).not.toEqual('audioId');
});

it('parses out audio Id from AWS URI', () => {
const res = parseAWSIdFromUri(
'https://igbo-api.s3.us-east-2.com/audio-pronunciations/audioId.mp3'
);
expect(res).toEqual('audioId');
});

it('does not parse out audio Id correctly for AWS URI', () => {
try {
parseAWSIdFromUri('audio-pronunciations/audioId.mp3');
} catch (err) {
expect(err).toBeTruthy();
}
});
});
Loading
Loading