From b50ee4cc2a3aa6af148fce3f9f44f36165f20395 Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Thu, 15 Aug 2024 07:25:54 -0700 Subject: [PATCH 1/8] feat: adds speech to text endpoint --- src/config.ts | 2 +- src/controllers/speechToText.ts | 78 +++++++++++++++++++ .../helpers/authorizeDeveloperUsage.ts | 69 ++++++++++++++++ src/middleware/helpers/findDeveloper.ts | 25 ++++++ src/middleware/validateApiKey.ts | 69 +++------------- src/models/Developer.ts | 4 + src/routers/routerV2.ts | 4 + src/types/developer.ts | 4 + 8 files changed, 194 insertions(+), 61 deletions(-) create mode 100644 src/controllers/speechToText.ts create mode 100644 src/middleware/helpers/authorizeDeveloperUsage.ts create mode 100644 src/middleware/helpers/findDeveloper.ts diff --git a/src/config.ts b/src/config.ts index 2e9aba4e..c89acc0a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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', diff --git a/src/controllers/speechToText.ts b/src/controllers/speechToText.ts new file mode 100644 index 00000000..f8b8b7be --- /dev/null +++ b/src/controllers/speechToText.ts @@ -0,0 +1,78 @@ +import axios from 'axios'; +import { MiddleWare } from '../types'; + +const SPEECH_TO_TEXT_API = 'https://speech.igboapi.com'; + +interface Prediction { + transcription: string; +} + +/** + * Parses out the document Id (typically the ExampleSuggestion) from the AWS URI. + * @param awsId AWS URI + * @returns Audio Id + */ +const parseAWSId = (awsId: string) => awsId.split('.')[0].split('/')[1]; + +/** + * Fetches the audio from the url and returns its base64 string + * @param url + * @returns base64 string of audio + */ +const fetchBase64Data = async (url: string) => { + const fetchedAudio = await fetch(url); + const data = await fetchedAudio.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(data))); + return base64; +}; + +/** + * 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://')) { + throw new Error('Audio URL must be hosted publicly.'); + } + + let payload = { id: '', url: '' }; + const base64 = fetchBase64Data(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({ + method: 'POST', + url: `${SPEECH_TO_TEXT_API}/audio`, + headers: { + 'Content-Type': 'application/json', + }, + data: { base64 }, + }); + + const audioId = parseAWSId(response.Key); + payload = { id: audioId, url: response.Location }; + } else { + const audioId = parseAWSId(audio); + payload = { id: audioId, url: audio }; + } + + // Talks to prediction endpoint + const { data: response } = await axios.request({ + method: 'POST', + url: `${SPEECH_TO_TEXT_API}/predict`, + headers: { + 'Content-Type': 'application/json', + }, + data: payload, + }); + + return res.send(response); + } catch (err) { + return next(); + } +}; diff --git a/src/middleware/helpers/authorizeDeveloperUsage.ts b/src/middleware/helpers/authorizeDeveloperUsage.ts new file mode 100644 index 00000000..b01d0a25 --- /dev/null +++ b/src/middleware/helpers/authorizeDeveloperUsage.ts @@ -0,0 +1,69 @@ +import { DeveloperDocument } from '../../types'; + +enum ApiType { + IGBO = 'IGBO', + SPEECH_TO_TEXT = 'SPEECH_TO_TEXT', +} + +const PROD_IGBO_API_LIMIT = 2500; +const PROD_SPEECH_TO_TEXT_LIMIT = 20; + +const isSameDate = (first: Date, second: Date) => + first.getFullYear() === second.getFullYear() && + first.getMonth() === second.getMonth() && + first.getDate() === second.getDate(); + +/* Increments usage count and updates usage date */ +const handleDeveloperUsage = async ({ + developer, + apiType, +}: { + developer: DeveloperDocument, + apiType: ApiType, +}) => { + const updatedDeveloper = developer; + if (!updatedDeveloper) { + throw new Error('No developer found'); + } + const isNewDay = !isSameDate(updatedDeveloper.usage.date, new Date()); + updatedDeveloper.usage.date = new Date(); + + switch (apiType) { + case ApiType.IGBO: + updatedDeveloper.usage.count = isNewDay ? 0 : updatedDeveloper.usage.count + 1; + break; + case ApiType.SPEECH_TO_TEXT: + updatedDeveloper.speechToTextUsage.count = isNewDay + ? 0 + : updatedDeveloper.speechToTextUsage.count + 1; + break; + default: + break; + } + + return updatedDeveloper.save(); +}; + +/** + * Authorizes the developer to use the current route if they are under their daily limit. + * @param param0 + */ +export const authorizeDeveloperUsage = async ({ + route, + developer, +}: { + route: string, + developer: DeveloperDocument, +}) => { + if (route.startsWith('speech-to-text')) { + if (developer.speechToTextUsage.count >= PROD_SPEECH_TO_TEXT_LIMIT) { + throw new Error('You have exceeded your limit of Igbo API requests for the day.'); + } + handleDeveloperUsage({ developer, apiType: ApiType.IGBO }); + } else { + if (developer.usage.count >= PROD_IGBO_API_LIMIT) { + throw new Error('You have exceeded your Speech-to-Text requests for the day.'); + } + handleDeveloperUsage({ developer, apiType: ApiType.SPEECH_TO_TEXT }); + } +}; diff --git a/src/middleware/helpers/findDeveloper.ts b/src/middleware/helpers/findDeveloper.ts new file mode 100644 index 00000000..e9e6fa8c --- /dev/null +++ b/src/middleware/helpers/findDeveloper.ts @@ -0,0 +1,25 @@ +import { compareSync } from 'bcrypt'; +import { developerSchema } from '../../models/Developer'; +import { createDbConnection } from '../../services/database'; +import { DeveloperDocument } from '../../types'; + +/* Finds a developer with provided information */ +export const findDeveloper = async (apiKey: string) => { + const connection = createDbConnection(); + const Developer = connection.model('Developer', developerSchema); + let developer = await Developer.findOne({ apiKey }); + if (developer) { + return developer; + } + // Legacy implementation: hashed API tokens can't be indexed + // This logic attempts to find the developer document and update it + // with the API token + const developers = await Developer.find({}); + developer = developers.find((dev) => compareSync(apiKey, dev.apiKey)) || null; + if (developer) { + developer.apiKey = apiKey; + const updatedDeveloper = await developer.save(); + return updatedDeveloper; + } + return developer; +}; diff --git a/src/middleware/validateApiKey.ts b/src/middleware/validateApiKey.ts index 01f9b0da..ac3a283a 100644 --- a/src/middleware/validateApiKey.ts +++ b/src/middleware/validateApiKey.ts @@ -1,59 +1,12 @@ -import { compareSync } from 'bcrypt'; -import { developerSchema } from '../models/Developer'; -import { MAIN_KEY, isTest, isDevelopment, isProduction } from '../config'; -import { createDbConnection } from '../services/database'; -import { DeveloperDocument, MiddleWare } from '../types'; +import { authorizeDeveloperUsage } from 'src/middleware/helpers/authorizeDeveloperUsage'; +import { MAIN_KEY, isDevelopment, isProduction } from '../config'; +import { MiddleWare } from '../types'; +import { findDeveloper } from './helpers/findDeveloper'; -const PROD_LIMIT = 2500; const FALLBACK_API_KEY = 'fallback_api_key'; -const determineLimit = (apiLimit = '') => - isTest ? parseInt(apiLimit, 10) || PROD_LIMIT : PROD_LIMIT; - -const isSameDate = (first: Date, second: Date) => - first.getFullYear() === second.getFullYear() && - first.getMonth() === second.getMonth() && - first.getDate() === second.getDate(); - -/* Increments usage count and updates usage date */ -const handleDeveloperUsage = async (developer: DeveloperDocument) => { - const updatedDeveloper = developer; - const isNewDay = !isSameDate(updatedDeveloper.usage.date, new Date()); - updatedDeveloper.usage.date = new Date(); - - if (isNewDay) { - updatedDeveloper.usage.count = 0; - } else { - updatedDeveloper.usage.count += 1; - } - - return updatedDeveloper.save(); -}; - -/* Finds a developer with provided information */ -const findDeveloper = async (apiKey: string) => { - const connection = createDbConnection(); - const Developer = connection.model('Developer', developerSchema); - let developer = await Developer.findOne({ apiKey }); - if (developer) { - return developer; - } - // Legacy implementation: hashed API tokens can't be indexed - // This logic attempts to find the developer document and update it - // with the API token - const developers = await Developer.find({}); - developer = developers.find((dev) => compareSync(apiKey, dev.apiKey)) || null; - if (developer) { - developer.apiKey = apiKey; - const updatedDeveloper = await developer.save(); - return updatedDeveloper; - } - return developer; -}; - const validateApiKey: MiddleWare = async (req, res, next) => { try { - const { apiLimit } = req.query; let apiKey = (req.headers['X-API-Key'] || req.headers['x-api-key']) as string; /* Official sites can bypass validation */ @@ -77,17 +30,13 @@ const validateApiKey: MiddleWare = async (req, res, next) => { const developer = await findDeveloper(apiKey); - if (developer) { - if (developer.usage.count >= determineLimit(apiLimit)) { - res.status(403); - return res.send({ error: 'You have exceeded your limit of requests for the day' }); - } - await handleDeveloperUsage(developer); - return next(); + if (!developer) { + return res.status(401).send({ error: 'Your API key is invalid' }); } - res.status(401); - return res.send({ error: 'Your API key is invalid' }); + await authorizeDeveloperUsage({ route: req.route, developer }); + + return next(); } catch (err: any) { res.status(400); return res.send({ error: err.message }); diff --git a/src/models/Developer.ts b/src/models/Developer.ts index f3a4eccf..9ac4985d 100644 --- a/src/models/Developer.ts +++ b/src/models/Developer.ts @@ -19,6 +19,10 @@ export const developerSchema = new Schema( date: { type: Date, default: new Date().toISOString() }, count: { type: Number, default: 0 }, }, + speechToTextUsage: { + date: { type: Date, default: new Date().toISOString() }, + count: { type: Number, default: 0 }, + }, firebaseId: { type: String, default: '' }, stripeId: { type: String, default: '' }, plan: { type: String, enum: Object.values(Plan), default: Plan.STARTER }, diff --git a/src/routers/routerV2.ts b/src/routers/routerV2.ts index 5a96bc91..de88796c 100644 --- a/src/routers/routerV2.ts +++ b/src/routers/routerV2.ts @@ -2,6 +2,7 @@ import { Router } from 'express'; import { getWords, getWord } from '../controllers/words'; import { getExample, getExamples } from '../controllers/examples'; import { getNsibidiCharacter, getNsibidiCharacters } from '../controllers/nsibidi'; +import { getTranscription } from '../controllers/speechToText'; import validId from '../middleware/validId'; import validateApiKey from '../middleware/validateApiKey'; import analytics from '../middleware/analytics'; @@ -23,6 +24,9 @@ routerV2.get( getNsibidiCharacter ); +// Speech-to-Text +routerV2.post('/speech-to-text', analytics, validateApiKey, getTranscription); + // Redirects to V1 routerV2.post('/developers', (_, res) => res.redirect('/api/v1/developers')); routerV2.get('/stats', (_, res) => res.redirect('/api/v1/stats')); diff --git a/src/types/developer.ts b/src/types/developer.ts index 5e0ad36e..a050d623 100644 --- a/src/types/developer.ts +++ b/src/types/developer.ts @@ -18,6 +18,10 @@ export interface Developer extends DeveloperClientData { date: Date, count: number, }; + speechToTextUsage: { + date: Date, + count: number, + }; } export interface DeveloperDocument extends Developer, Document { From 0a2d37ce3963449ed8f14b01ee4929f901c4d5b6 Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Wed, 28 Aug 2024 17:40:26 -0700 Subject: [PATCH 2/8] feat(speech-to-text): expose speech to text endpoint --- __mocks__/@sendgrid/mail.ts | 1 + __mocks__/shelljs.ts | 3 + jest.backend.config.ts | 4 + src/__tests__/shared/fixtures.ts | 41 +++++++++- src/config.ts | 4 +- src/controllers/email.ts | 20 +++-- .../helpers/authorizeDeveloperUsage.ts | 74 +++++++++---------- .../helpers/createDeveloperUsage.ts | 16 ++++ src/middleware/helpers/findDeveloperUsage.ts | 25 +++++++ src/middleware/validateApiKey.ts | 4 +- src/models/Developer.ts | 6 +- src/models/DeveloperUsage.ts | 18 +++++ src/shared/constants/ApiType.ts | 6 ++ src/shared/constants/ApiUsageLimit.ts | 8 ++ src/types/developer.ts | 4 - src/types/developerUsage.ts | 15 ++++ src/types/index.ts | 1 + testSetup.ts | 1 + 18 files changed, 190 insertions(+), 61 deletions(-) create mode 100644 __mocks__/@sendgrid/mail.ts create mode 100644 __mocks__/shelljs.ts create mode 100644 src/middleware/helpers/createDeveloperUsage.ts create mode 100644 src/middleware/helpers/findDeveloperUsage.ts create mode 100644 src/models/DeveloperUsage.ts create mode 100644 src/shared/constants/ApiType.ts create mode 100644 src/shared/constants/ApiUsageLimit.ts create mode 100644 src/types/developerUsage.ts diff --git a/__mocks__/@sendgrid/mail.ts b/__mocks__/@sendgrid/mail.ts new file mode 100644 index 00000000..ff8b4c56 --- /dev/null +++ b/__mocks__/@sendgrid/mail.ts @@ -0,0 +1 @@ +export default {}; diff --git a/__mocks__/shelljs.ts b/__mocks__/shelljs.ts new file mode 100644 index 00000000..99e42d8f --- /dev/null +++ b/__mocks__/shelljs.ts @@ -0,0 +1,3 @@ +export default { + mkdir: jest.fn(), +}; diff --git a/jest.backend.config.ts b/jest.backend.config.ts index 2fe06b4f..95df1e32 100644 --- a/jest.backend.config.ts +++ b/jest.backend.config.ts @@ -9,7 +9,11 @@ export default { ], testTimeout: 20000, testEnvironment: 'node', + roots: ['/src', ''], moduleFileExtensions: ['ts', 'js', 'json'], + moduleNameMapper: { + 'src/(.*)': '/src/$1', + }, transform: { '^.+\\.tsx?$': 'ts-jest', }, diff --git a/src/__tests__/shared/fixtures.ts b/src/__tests__/shared/fixtures.ts index c1b128da..716d35a8 100644 --- a/src/__tests__/shared/fixtures.ts +++ b/src/__tests__/shared/fixtures.ts @@ -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) => ({ definitions: [], @@ -55,3 +67,30 @@ export const exampleFixture = (exampleData: Partial) => ({ origin: SuggestionSourceEnum.INTERNAL, ...exampleData, }); + +export const developerFixture = (developerData: Partial) => ({ + 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) => ({ + developerId: '', + usageType: ApiType.DICTIONARY, + usage: { + date: new Date(), + count: 0, + }, + ...developerFixture, +}); diff --git a/src/config.ts b/src/config.ts index c89acc0a..95f7e65a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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(); @@ -108,7 +107,8 @@ export const SENDGRID_NEW_DEVELOPER_ACCOUNT_TEMPLATE = SENDGRID_NEW_DEVELOPER_ACCOUNT_TEMPLATE_SOURCE; export const API_FROM_EMAIL = 'kedu@nkowaokwu.com'; -if (sgMail && !isTest) { +if (process.env.NODE_ENV !== Environment.BUILD && !isTest) { + const sgMail = require('@sendgrid/mail'); // eslint-disable-line sgMail.setApiKey(SENDGRID_API_KEY); } diff --git a/src/controllers/email.ts b/src/controllers/email.ts index 6c6cd881..8170d6ce 100644 --- a/src/controllers/email.ts +++ b/src/controllers/email.ts @@ -7,14 +7,9 @@ import { 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, @@ -29,8 +24,16 @@ const constructMessage = (messageFields: EmailTemplate) => ({ }); /* 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(() => { @@ -51,6 +54,7 @@ export const sendEmail = (message: MailDataRequired) => } return Promise.resolve(); })(); +}; type DeveloperEmailConfig = { apiKey: string, diff --git a/src/middleware/helpers/authorizeDeveloperUsage.ts b/src/middleware/helpers/authorizeDeveloperUsage.ts index b01d0a25..5c1d79db 100644 --- a/src/middleware/helpers/authorizeDeveloperUsage.ts +++ b/src/middleware/helpers/authorizeDeveloperUsage.ts @@ -1,13 +1,11 @@ +import { Types } from 'mongoose'; +import { findDeveloperUsage } from './findDeveloperUsage'; +import { createDeveloperUsage } from './createDeveloperUsage'; +import { DeveloperUsageDocument } from '../../types/developerUsage'; +import ApiType from '../../shared/constants/ApiType'; +import ApiUsageLimit from '../../shared/constants/ApiUsageLimit'; import { DeveloperDocument } from '../../types'; -enum ApiType { - IGBO = 'IGBO', - SPEECH_TO_TEXT = 'SPEECH_TO_TEXT', -} - -const PROD_IGBO_API_LIMIT = 2500; -const PROD_SPEECH_TO_TEXT_LIMIT = 20; - const isSameDate = (first: Date, second: Date) => first.getFullYear() === second.getFullYear() && first.getMonth() === second.getMonth() && @@ -20,28 +18,34 @@ const handleDeveloperUsage = async ({ }: { developer: DeveloperDocument, apiType: ApiType, -}) => { - const updatedDeveloper = developer; - if (!updatedDeveloper) { - throw new Error('No developer found'); +}): Promise => { + const currentDate = new Date(); + let developerUsage = await findDeveloperUsage({ + developerId: developer.id.toString(), + usageType: apiType, + }); + + if (!developerUsage && apiType === ApiType.DICTIONARY) { + developerUsage = await createDeveloperUsage({ + developerId: new Types.ObjectId(developer.id.toString()), + }); + } + + if (!developerUsage) { + throw new Error('No developer usage found'); } - const isNewDay = !isSameDate(updatedDeveloper.usage.date, new Date()); - updatedDeveloper.usage.date = new Date(); - switch (apiType) { - case ApiType.IGBO: - updatedDeveloper.usage.count = isNewDay ? 0 : updatedDeveloper.usage.count + 1; - break; - case ApiType.SPEECH_TO_TEXT: - updatedDeveloper.speechToTextUsage.count = isNewDay - ? 0 - : updatedDeveloper.speechToTextUsage.count + 1; - break; - default: - break; + if (developerUsage.usage.count >= ApiUsageLimit[apiType]) { + throw new Error('You have exceeded your limit for this API for the day.'); } - return updatedDeveloper.save(); + const isNewDay = !isSameDate(developerUsage.usage.date, currentDate); + developerUsage.usage.count = isNewDay ? 0 : developerUsage.usage.count + 1; + developerUsage.usage.date = currentDate; + + developerUsage.markModified('usage'); + + return developerUsage.save(); }; /** @@ -54,16 +58,8 @@ export const authorizeDeveloperUsage = async ({ }: { route: string, developer: DeveloperDocument, -}) => { - if (route.startsWith('speech-to-text')) { - if (developer.speechToTextUsage.count >= PROD_SPEECH_TO_TEXT_LIMIT) { - throw new Error('You have exceeded your limit of Igbo API requests for the day.'); - } - handleDeveloperUsage({ developer, apiType: ApiType.IGBO }); - } else { - if (developer.usage.count >= PROD_IGBO_API_LIMIT) { - throw new Error('You have exceeded your Speech-to-Text requests for the day.'); - } - handleDeveloperUsage({ developer, apiType: ApiType.SPEECH_TO_TEXT }); - } -}; +}) => + handleDeveloperUsage({ + developer, + apiType: route.startsWith('speech-to-text') ? ApiType.SPEECH_TO_TEXT : ApiType.DICTIONARY, + }); diff --git a/src/middleware/helpers/createDeveloperUsage.ts b/src/middleware/helpers/createDeveloperUsage.ts new file mode 100644 index 00000000..b885dcb3 --- /dev/null +++ b/src/middleware/helpers/createDeveloperUsage.ts @@ -0,0 +1,16 @@ +import { Types } from 'mongoose'; +import { developerUsageSchema } from '../../models/DeveloperUsage'; +import { createDbConnection } from '../../services/database'; +import ApiType from '../../shared/constants/ApiType'; +import { DeveloperUsageDocument } from '../../types'; + +/* Creates a fallback Developer Usage document */ +export const createDeveloperUsage = async ({ developerId }: { developerId: Types.ObjectId }) => { + const connection = createDbConnection(); + const DeveloperUsage = connection.model( + 'DeveloperUsage', + developerUsageSchema + ); + const developerUsage = new DeveloperUsage({ developerId, usageType: ApiType.DICTIONARY }); + return developerUsage.save(); +}; diff --git a/src/middleware/helpers/findDeveloperUsage.ts b/src/middleware/helpers/findDeveloperUsage.ts new file mode 100644 index 00000000..edccc23b --- /dev/null +++ b/src/middleware/helpers/findDeveloperUsage.ts @@ -0,0 +1,25 @@ +import { developerUsageSchema } from '../../models/DeveloperUsage'; +import { createDbConnection } from '../../services/database'; +import ApiType from '../../shared/constants/ApiType'; +import { DeveloperUsageDocument } from '../../types/developerUsage'; + +/* Finds developer's usage for a part of the API */ +export const findDeveloperUsage = async ({ + developerId, + usageType, +}: { + developerId: string, + usageType: ApiType, +}): Promise => { + const connection = createDbConnection(); + const DeveloperUsage = connection.model( + 'DeveloperUsage', + developerUsageSchema + ); + const developerUsage = await DeveloperUsage.findOne({ developerId, usageType }); + if (developerUsage) { + return developerUsage; + } + + return undefined; +}; diff --git a/src/middleware/validateApiKey.ts b/src/middleware/validateApiKey.ts index ac3a283a..6193fd5b 100644 --- a/src/middleware/validateApiKey.ts +++ b/src/middleware/validateApiKey.ts @@ -1,7 +1,7 @@ -import { authorizeDeveloperUsage } from 'src/middleware/helpers/authorizeDeveloperUsage'; +import { authorizeDeveloperUsage } from './helpers/authorizeDeveloperUsage'; +import { findDeveloper } from './helpers/findDeveloper'; import { MAIN_KEY, isDevelopment, isProduction } from '../config'; import { MiddleWare } from '../types'; -import { findDeveloper } from './helpers/findDeveloper'; const FALLBACK_API_KEY = 'fallback_api_key'; diff --git a/src/models/Developer.ts b/src/models/Developer.ts index 9ac4985d..ef9cdd01 100644 --- a/src/models/Developer.ts +++ b/src/models/Developer.ts @@ -18,11 +18,7 @@ export const developerSchema = new Schema( usage: { date: { type: Date, default: new Date().toISOString() }, count: { type: Number, default: 0 }, - }, - speechToTextUsage: { - date: { type: Date, default: new Date().toISOString() }, - count: { type: Number, default: 0 }, - }, + }, // DEPRECATED: Please use DeveloperUsage firebaseId: { type: String, default: '' }, stripeId: { type: String, default: '' }, plan: { type: String, enum: Object.values(Plan), default: Plan.STARTER }, diff --git a/src/models/DeveloperUsage.ts b/src/models/DeveloperUsage.ts new file mode 100644 index 00000000..519140d3 --- /dev/null +++ b/src/models/DeveloperUsage.ts @@ -0,0 +1,18 @@ +import mongoose from 'mongoose'; +import ApiType from '../shared/constants/ApiType'; +import { toJSONPlugin, toObjectPlugin } from './plugins'; + +const { Schema, Types } = mongoose; +export const developerUsageSchema = new Schema( + { + developerId: { type: Types.ObjectId, ref: 'Developer', required: true }, + usageType: { type: String, enum: ApiType, default: ApiType.DICTIONARY }, + usage: { + date: { type: Date, default: new Date().toISOString() }, + count: { type: Number, default: 0 }, + }, + }, + { toObject: toObjectPlugin, timestamps: true } +); + +toJSONPlugin(developerUsageSchema); diff --git a/src/shared/constants/ApiType.ts b/src/shared/constants/ApiType.ts new file mode 100644 index 00000000..d78db019 --- /dev/null +++ b/src/shared/constants/ApiType.ts @@ -0,0 +1,6 @@ +enum ApiType { + DICTIONARY = 'DICTIONARY', + SPEECH_TO_TEXT = 'SPEECH_TO_TEXT', +} + +export default ApiType; diff --git a/src/shared/constants/ApiUsageLimit.ts b/src/shared/constants/ApiUsageLimit.ts new file mode 100644 index 00000000..368f5456 --- /dev/null +++ b/src/shared/constants/ApiUsageLimit.ts @@ -0,0 +1,8 @@ +import ApiType from './ApiType'; + +const ApiUsageLimit = { + [ApiType.DICTIONARY]: 2500, + [ApiType.SPEECH_TO_TEXT]: 20, +}; + +export default ApiUsageLimit; diff --git a/src/types/developer.ts b/src/types/developer.ts index a050d623..5e0ad36e 100644 --- a/src/types/developer.ts +++ b/src/types/developer.ts @@ -18,10 +18,6 @@ export interface Developer extends DeveloperClientData { date: Date, count: number, }; - speechToTextUsage: { - date: Date, - count: number, - }; } export interface DeveloperDocument extends Developer, Document { diff --git a/src/types/developerUsage.ts b/src/types/developerUsage.ts new file mode 100644 index 00000000..81322a25 --- /dev/null +++ b/src/types/developerUsage.ts @@ -0,0 +1,15 @@ +import { Document, Types } from 'mongoose'; +import ApiType from 'src/shared/constants/ApiType'; + +export interface DeveloperUsage { + developerId: Types.ObjectId | string; + usageType: ApiType; + usage: { + date: Date, + count: number, + }; +} + +export interface DeveloperUsageDocument extends DeveloperUsage, Document { + id: Types.ObjectId; +} diff --git a/src/types/index.ts b/src/types/index.ts index 21ab59d7..37981b15 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ export type { OutgoingLegacyExample, ExampleDocument, } from './example'; +export type { DeveloperUsage, DeveloperUsageDocument } from './developerUsage'; export type { Stat } from './stat'; export type { IncomingWord, diff --git a/testSetup.ts b/testSetup.ts index 30cee1e6..5f8b7e41 100644 --- a/testSetup.ts +++ b/testSetup.ts @@ -10,6 +10,7 @@ export default async () => { await mongoose.connection.collection('words').dropIndexes(); await mongoose.connection.collection('examples').dropIndexes(); await mongoose.connection.collection('developers').dropIndexes(); + await mongoose.connection.collection('developerUsages').dropIndexes(); await mongoose.connection.collection('nsibidicharacters').dropIndexes(); await mongoose.connection.db.dropDatabase(); } From 0beeff7acf908a39078528da5c67eb2df8b00929 Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Wed, 28 Aug 2024 17:40:58 -0700 Subject: [PATCH 3/8] test: create new tests and update existing ones --- .../__tests__/authorizeDeveloperUsage.test.ts | 97 +++++++++++++++++++ .../__tests__/createDeveloperUsage.test.ts | 26 +++++ .../__tests__/findDeveloperUsage.test.ts | 37 +++++++ src/services/__tests__/database.test.ts | 4 +- 4 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 src/middleware/helpers/__tests__/authorizeDeveloperUsage.test.ts create mode 100644 src/middleware/helpers/__tests__/createDeveloperUsage.test.ts create mode 100644 src/middleware/helpers/__tests__/findDeveloperUsage.test.ts diff --git a/src/middleware/helpers/__tests__/authorizeDeveloperUsage.test.ts b/src/middleware/helpers/__tests__/authorizeDeveloperUsage.test.ts new file mode 100644 index 00000000..83cef52b --- /dev/null +++ b/src/middleware/helpers/__tests__/authorizeDeveloperUsage.test.ts @@ -0,0 +1,97 @@ +import { authorizeDeveloperUsage } from '../authorizeDeveloperUsage'; +import { + developerFixture, + developerUsageFixture, + documentId, +} from '../../../__tests__/shared/fixtures'; +import { findDeveloperUsage } from '../findDeveloperUsage'; +import { createDeveloperUsage } from '../createDeveloperUsage'; +import ApiType from '../../../shared/constants/ApiType'; +import ApiUsageLimit from '../../../shared/constants/ApiUsageLimit'; + +jest.mock('../findDeveloperUsage'); +jest.mock('../createDeveloperUsage'); + +describe('authorizeDeveloperUsage', () => { + it("authorizes the current developer's usage", async () => { + const route = 'speech-to-text'; + const developer = developerFixture({}); + const developerUsage = developerUsageFixture({}); + // @ts-expect-error mockReturnValue + findDeveloperUsage.mockReturnValue({ + ...developerUsage, + save: jest.fn(() => developerUsage), + markModified: jest.fn(), + }); + + // @ts-expect-error developer + const res = await authorizeDeveloperUsage({ route, developer }); + expect(res).toEqual(developerUsage); + }); + + it("updates the current developer's usage", async () => { + const route = 'speech-to-text'; + const developer = developerFixture({}); + const developerUsage = developerUsageFixture({}); + const developerUsageDocument = { + ...developerUsage, + save: jest.fn(() => developerUsage), + markModified: jest.fn(), + }; + // @ts-expect-error mockReturnValue + findDeveloperUsage.mockReturnValue(developerUsageDocument); + + // @ts-expect-error developer + await authorizeDeveloperUsage({ route, developer }); + expect(developerUsageDocument.usage.count).toEqual(1); + }); + + it('creates a fallback developer usage if none exist exclusively for Igbo API', async () => { + const route = 'igbo_api'; + const developer = developerFixture({ id: documentId }); + const developerUsage = developerUsageFixture({}); + const developerUsageDocument = { + ...developerUsage, + save: jest.fn(() => developerUsage), + markModified: jest.fn(), + }; + // @ts-expect-error mockReturnValue + findDeveloperUsage.mockReturnValue(undefined); + // @ts-expect-error mockReturnValue + createDeveloperUsage.mockReturnValue(developerUsageDocument); + // @ts-expect-error developer + await authorizeDeveloperUsage({ route, developer }); + expect(createDeveloperUsage).toHaveBeenCalled(); + }); + + it('throws error unable finding developer usage', async () => { + const route = 'speech-to-text'; + const developer = developerFixture({}); + // @ts-expect-error mockReturnValue + findDeveloperUsage.mockReturnValue(undefined); + + // @ts-expect-error developer + authorizeDeveloperUsage({ route, developer }).catch((err) => { + expect(err.message).toEqual('No developer usage found'); + }); + }); + + it('throws error due to exceeding usage limit', async () => { + const route = 'speech-to-text'; + const developer = developerFixture({}); + const developerUsage = developerUsageFixture({ + usage: { date: new Date(), count: ApiUsageLimit[ApiType.SPEECH_TO_TEXT] + 1 }, + }); + // @ts-expect-error mockReturnValue + findDeveloperUsage.mockReturnValue({ + ...developerUsage, + save: jest.fn(() => developerUsage), + markModified: jest.fn(), + }); + + // @ts-expect-error developer + authorizeDeveloperUsage({ route, developer }).catch((err) => { + expect(err.message).toEqual('You have exceeded your limit for this API for the day.'); + }); + }); +}); diff --git a/src/middleware/helpers/__tests__/createDeveloperUsage.test.ts b/src/middleware/helpers/__tests__/createDeveloperUsage.test.ts new file mode 100644 index 00000000..84ba7ace --- /dev/null +++ b/src/middleware/helpers/__tests__/createDeveloperUsage.test.ts @@ -0,0 +1,26 @@ +import { Types } from 'mongoose'; +import { createDeveloperUsage } from '../createDeveloperUsage'; +import { documentId } from '../../../__tests__/shared/fixtures'; +import { createDbConnection } from '../../../services/database'; + +jest.mock('../../../services/database'); + +const saveMock = jest.fn(); +class DeveloperUsage { + constructor() { + return { + save: saveMock, + }; + } +} + +describe('createDeveloperUsage', () => { + it('creates a new developer usage document', async () => { + const developerId = new Types.ObjectId(documentId); + // @ts-expect-error mockReturnValue + createDbConnection.mockReturnValue({ model: jest.fn(() => DeveloperUsage) }); + + await createDeveloperUsage({ developerId: documentId }); + expect(saveMock).toHaveBeenCalled(); + }); +}); diff --git a/src/middleware/helpers/__tests__/findDeveloperUsage.test.ts b/src/middleware/helpers/__tests__/findDeveloperUsage.test.ts new file mode 100644 index 00000000..ea0f77e3 --- /dev/null +++ b/src/middleware/helpers/__tests__/findDeveloperUsage.test.ts @@ -0,0 +1,37 @@ +import { findDeveloperUsage } from '../findDeveloperUsage'; +import ApiType from '../../../shared/constants/ApiType'; +import { createDbConnection } from '../../../services/database'; + +jest.mock('../../../services/database'); + +describe('findDeveloperUsage', () => { + it('finds a specific developer usage', async () => { + const developerId = 'developerId'; + const findOneMock = jest.fn(() => ({ id: developerId })); + // @ts-expect-error mockReturnValue + createDbConnection.mockReturnValue({ + model: jest.fn(() => ({ + findOne: findOneMock, + })), + }); + + const res = await findDeveloperUsage({ developerId, usageType: ApiType.DICTIONARY }); + expect(res).toEqual({ id: developerId }); + expect(findOneMock).toHaveBeenCalledWith({ developerId, usageType: ApiType.DICTIONARY }); + }); + + it('returns undefined if no developer usage is found', async () => { + const developerId = 'developerId'; + const findOneMock = jest.fn(() => undefined); + // @ts-expect-error mockReturnValue + createDbConnection.mockReturnValue({ + model: jest.fn(() => ({ + findOne: findOneMock, + })), + }); + + const res = await findDeveloperUsage({ developerId, usageType: ApiType.DICTIONARY }); + expect(res).toEqual(undefined); + expect(findOneMock).toHaveBeenCalledWith({ developerId, usageType: ApiType.DICTIONARY }); + }); +}); diff --git a/src/services/__tests__/database.test.ts b/src/services/__tests__/database.test.ts index d4c560f8..fc8aecef 100644 --- a/src/services/__tests__/database.test.ts +++ b/src/services/__tests__/database.test.ts @@ -8,8 +8,8 @@ describe('database', () => { }); it('disconnects from the database', async () => { - const connection = createDbConnection(); - await handleCloseConnection(connection); + // @ts-expect-error connection + await handleCloseConnection({ readyState: 1, close: closeMock }); expect(closeMock).toHaveBeenCalled(); }); }); From 1b0330a6d6a0f0ad490d9f492575cac5b2728f10 Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Wed, 28 Aug 2024 18:29:00 -0700 Subject: [PATCH 4/8] fix: address bug for parsing audio Ids from AWS resources --- __mocks__/axios.ts | 3 ++- src/controllers/speechToText.ts | 27 ++++--------------- .../utils/__mocks__/fetchBase64Data.ts | 1 + src/controllers/utils/fetchBase64Data.ts | 11 ++++++++ src/controllers/utils/parseAWS.ts | 18 +++++++++++++ 5 files changed, 37 insertions(+), 23 deletions(-) create mode 100644 src/controllers/utils/__mocks__/fetchBase64Data.ts create mode 100644 src/controllers/utils/fetchBase64Data.ts create mode 100644 src/controllers/utils/parseAWS.ts diff --git a/__mocks__/axios.ts b/__mocks__/axios.ts index 82861710..e7227c55 100644 --- a/__mocks__/axios.ts +++ b/__mocks__/axios.ts @@ -1,3 +1,4 @@ export const mockRequest = jest.fn((config) => config); +export const request = jest.fn(); -export default mockRequest; +export default { default: mockRequest, request }; diff --git a/src/controllers/speechToText.ts b/src/controllers/speechToText.ts index f8b8b7be..930c06dd 100644 --- a/src/controllers/speechToText.ts +++ b/src/controllers/speechToText.ts @@ -1,5 +1,7 @@ import axios from 'axios'; import { MiddleWare } from '../types'; +import { fetchBase64Data } from './utils/fetchBase64Data'; +import { parseAWSIdFromKey, parseAWSIdFromUri } from './utils/parseAWS'; const SPEECH_TO_TEXT_API = 'https://speech.igboapi.com'; @@ -7,25 +9,6 @@ interface Prediction { transcription: string; } -/** - * Parses out the document Id (typically the ExampleSuggestion) from the AWS URI. - * @param awsId AWS URI - * @returns Audio Id - */ -const parseAWSId = (awsId: string) => awsId.split('.')[0].split('/')[1]; - -/** - * Fetches the audio from the url and returns its base64 string - * @param url - * @returns base64 string of audio - */ -const fetchBase64Data = async (url: string) => { - const fetchedAudio = await fetch(url); - const data = await fetchedAudio.arrayBuffer(); - const base64 = btoa(String.fromCharCode(...new Uint8Array(data))); - return base64; -}; - /** * Talks to Speech-to-Text model to transcribe provided audio URL * @param req @@ -41,7 +24,7 @@ export const getTranscription: MiddleWare = async (req, res, next) => { } let payload = { id: '', url: '' }; - const base64 = fetchBase64Data(audio); + const base64 = await fetchBase64Data(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')) { @@ -54,10 +37,10 @@ export const getTranscription: MiddleWare = async (req, res, next) => { data: { base64 }, }); - const audioId = parseAWSId(response.Key); + const audioId = parseAWSIdFromKey(response.Key); payload = { id: audioId, url: response.Location }; } else { - const audioId = parseAWSId(audio); + const audioId = parseAWSIdFromUri(audio); payload = { id: audioId, url: audio }; } diff --git a/src/controllers/utils/__mocks__/fetchBase64Data.ts b/src/controllers/utils/__mocks__/fetchBase64Data.ts new file mode 100644 index 00000000..6edff8c2 --- /dev/null +++ b/src/controllers/utils/__mocks__/fetchBase64Data.ts @@ -0,0 +1 @@ +export const fetchBase64Data = jest.fn(() => ''); diff --git a/src/controllers/utils/fetchBase64Data.ts b/src/controllers/utils/fetchBase64Data.ts new file mode 100644 index 00000000..eca7dc21 --- /dev/null +++ b/src/controllers/utils/fetchBase64Data.ts @@ -0,0 +1,11 @@ +/** + * Fetches the audio from the url and returns its base64 string + * @param url + * @returns base64 string of audio + */ +export const fetchBase64Data = async (url: string) => { + const fetchedAudio = await fetch(url); + const data = await fetchedAudio.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(data))); + return base64; +}; diff --git a/src/controllers/utils/parseAWS.ts b/src/controllers/utils/parseAWS.ts new file mode 100644 index 00000000..28bea6db --- /dev/null +++ b/src/controllers/utils/parseAWS.ts @@ -0,0 +1,18 @@ +const AWS_AUDIO_PRONUNCIATIONS_DELIMITER = '/audio-pronunciations/'; + +/** + * Parses out the document Id (typically the ExampleSuggestion) from the AWS Key. + * (i.e. audio-pronunciations/.mp3) + * @param awsId AWS Key + * @returns Audio Id + */ +export const parseAWSIdFromKey = (awsId: string) => awsId.split('.')[0].split('/')[1]; + +/** + * Parses out the document Id (typically the ExampleSuggestion from the AWS URI). + * (i.e. https://igbo-api.s3.us-east-2.amazonaws.com/audio-pronunciations/.mp3) + * @param awsUri AWS URI + * @returns Audio Id + */ +export const parseAWSIdFromUri = (awsUri: string) => + awsUri.split(AWS_AUDIO_PRONUNCIATIONS_DELIMITER)[1].split('.')[0]; From 3ee6b44424cbdbd74d3ce9e736797b7cf0a4738f Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Wed, 28 Aug 2024 18:29:17 -0700 Subject: [PATCH 5/8] test: create new tests for speechToText --- .../__tests__/speechToText.test.ts | 85 +++++++++++++++++++ .../utils/__tests__/parseAWS.test.ts | 30 +++++++ 2 files changed, 115 insertions(+) create mode 100644 src/controllers/__tests__/speechToText.test.ts create mode 100644 src/controllers/utils/__tests__/parseAWS.test.ts diff --git a/src/controllers/__tests__/speechToText.test.ts b/src/controllers/__tests__/speechToText.test.ts new file mode 100644 index 00000000..ca019851 --- /dev/null +++ b/src/controllers/__tests__/speechToText.test.ts @@ -0,0 +1,85 @@ +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', () => { + it('calls audio IgboSpeech audio endpoint to upload audio', async () => { + const req = requestFixture({ body: { audioUrl: 'https://igboapi.com' } }); + const res = responseFixture(); + const next = nextFunctionFixture(); + const base64 = 'base64'; + // @ts-expect-error + fetchBase64Data.mockResolvedValue(base64); + // @ts-expect-error + axios.request.mockResolvedValue({ + data: { Key: '/audioId.com/', Location: 'https://igboapi.com' }, + }); + await getTranscription(req, res, next); + // @ts-expect-error + expect(axios.request.mock.calls[0][0]).toMatchObject({ + method: 'POST', + url: 'https://speech.igboapi.com/audio', + headers: { + 'Content-Type': 'application/json', + }, + data: { base64 }, + }); + // @ts-expect-error + expect(axios.request.mock.calls[1][0]).toMatchObject({ + method: 'POST', + url: 'https://speech.igboapi.com/predict', + headers: { + 'Content-Type': 'application/json', + }, + 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); + // @ts-expect-error + axios.request.mockResolvedValue({}); + await getTranscription(req, res, next); + expect(axios.request).not.toHaveBeenCalledWith({ + method: 'POST', + url: 'https://speech.igboapi.com/audio', + headers: { + 'Content-Type': 'application/json', + }, + data: { base64 }, + }); + expect(axios.request).toHaveBeenCalledWith({ + method: 'POST', + url: 'https://speech.igboapi.com/predict', + headers: { + 'Content-Type': 'application/json', + }, + 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(); + }); +}); diff --git a/src/controllers/utils/__tests__/parseAWS.test.ts b/src/controllers/utils/__tests__/parseAWS.test.ts new file mode 100644 index 00000000..7fd343e3 --- /dev/null +++ b/src/controllers/utils/__tests__/parseAWS.test.ts @@ -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(); + } + }); +}); From d4c075ad76e37e105c370713760fccc9cdd5bc3a Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Thu, 29 Aug 2024 09:02:27 -0700 Subject: [PATCH 6/8] chore: fix build error --- src/controllers/utils/expandNoun.ts | 95 -------- src/controllers/utils/expandVerb.ts | 341 ---------------------------- src/controllers/utils/index.ts | 13 +- src/controllers/utils/queries.ts | 6 - 4 files changed, 6 insertions(+), 449 deletions(-) delete mode 100644 src/controllers/utils/expandNoun.ts delete mode 100644 src/controllers/utils/expandVerb.ts diff --git a/src/controllers/utils/expandNoun.ts b/src/controllers/utils/expandNoun.ts deleted file mode 100644 index 74644e9c..00000000 --- a/src/controllers/utils/expandNoun.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { compact, isEqual } from 'lodash'; -import WordClassEnum from '../../shared/constants/WordClassEnum'; -import removeAccents from '../../shared/utils/removeAccents'; -import PartTypes from '../../shared/constants/PartTypes'; -import { Meta, WordData, Solution, TopSolution, MinimizedWord } from './types'; - -const nominalPrefixes = ['a', 'e', 'i', 'ị', 'o', 'ọ', 'u', 'ụ', 'n', 'nn', 'm', 'mm']; - -const isRootVerb = (root: string, wordData: WordData): MinimizedWord | undefined => - (wordData.verbs || []).find( - ({ word: headword, definitions = [] }) => - definitions.find( - ({ wordClass: nestedWordClass }) => nestedWordClass === WordClassEnum.AV || nestedWordClass === WordClassEnum.PV - ) && - (removeAccents.removeExcluding(headword).normalize('NFC') === root || - definitions.find(({ nsibidi }) => nsibidi === root)) - ); - -const topSolutions: TopSolution[] = []; -const helper = ( - word: string, - wordData: WordData, - firstPointer: number, - secondPointer: number, - topSolution: Solution[], - meta: Meta -): Solution[] => { - let localSecondPoint = secondPointer; - - const updatedMeta = { ...meta }; - updatedMeta.depth += 1; - const solutions = topSolution; - while (localSecondPoint <= word.length) { - const solution = [...topSolution]; - const currentRange = (word.substring(firstPointer, localSecondPoint) || '').trim(); - if (!currentRange) { - localSecondPoint += 1; // eslint-disable-line - } else { - if (updatedMeta.nominalPrefix && isRootVerb(currentRange, wordData)) { - solution.push({ - type: PartTypes.VERB_ROOT, - text: currentRange, - wordInfo: isRootVerb(currentRange, wordData), - wordClass: [WordClassEnum.AV, WordClassEnum.PV], - }); - updatedMeta.isPreviousVerb = true; - solutions.push(...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta)); - } else if (!updatedMeta.nominalPrefix && nominalPrefixes.includes(currentRange)) { - solution.push({ - type: PartTypes.NOMINAL_PREFIX, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - updatedMeta.negatorPrefixed = false; - updatedMeta.nominalPrefix = true; - solutions.push(...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, { ...updatedMeta })); - } - localSecondPoint += 1; - } - } - solutions.forEach((s) => { - if (s && !topSolutions.find(({ solution: currentSolution }) => isEqual(s, currentSolution))) { - topSolutions.push({ solution: s, metaData: updatedMeta }); - } - }); - return solutions || []; -}; - -// Backtracking -export default (rawWord: string, wordData: WordData): Solution[] => { - const word = rawWord.toLowerCase().normalize('NFC'); - const firstPointer = 0; - const secondPointer = 1; - const allWords = Object.values(wordData).flat(); - const verbs = allWords.filter(({ definitions = [] }) => - definitions.find( - ({ wordClass: nestedWordClass }) => nestedWordClass === WordClassEnum.AV || nestedWordClass === WordClassEnum.PV - ) - ); - helper(word, { verbs }, firstPointer, secondPointer, [], { depth: 0 }); - const finalSolution = - compact( - topSolutions.map(({ solution }) => { - const cleanedText = removeAccents - .removeExcluding([...solution.text].reduce((finalString, letter) => `${finalString}${letter}`, '') || '') - .normalize('NFC'); - if (cleanedText === word) { - console.warn('Cleaned text and word are equal'); - } - return solution; - }) - ) || []; - return finalSolution; -}; diff --git a/src/controllers/utils/expandVerb.ts b/src/controllers/utils/expandVerb.ts deleted file mode 100644 index ae6e24ae..00000000 --- a/src/controllers/utils/expandVerb.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { compact, isEqual } from 'lodash'; -import WordClassEnum from '../../shared/constants/WordClassEnum'; -import removeAccents from '../../shared/utils/removeAccents'; -import PartTypes from '../../shared/constants/PartTypes'; -import { Meta, WordData, Solution, TopSolution, MinimizedWord } from './types'; - -/* --- Definitions --- */ -/** - * depth = the current depth level of the backtracking tree - * isPreviousVerb = if the last node was a verb root - * isPreviousStativePrefix = if the last node was a stative pref (na-) - * isNegatorPrefixed = if the last node was a negator prefix (i.e. e) - * negativePrefix = keeps track of the actual value of the negatorPrefix (used for vowel harmony) - */ - -const suffixes = ['we', 'wa']; - -const negativesOrPast = ['le', 'la']; - -// TODO: verify that negative prefix matches with negatives -const negativePrefixes = ['a', 'e']; - -// TODO: check if stative prefix matches -const stativePairs = ['a', 'e']; - -const nots = ['ghi', 'ghị']; - -// TODO: verify that rv matches with preceding vowel -const rv = ['ra', 're', 'ro']; - -const prefixes = ['i', 'ị']; - -// TODO: verify that imperatives match with root verb -const imperatives = ['a', 'e', 'o', 'ọ']; - -// TODO: verify that this is after a root verb and is vowel harmonizing -const multiplePeople = ['nu', 'nụ']; - -// TODO: verify vowel harmony is correct -const stativePrefixes = ['na-']; - -const hasNotYet = ['be']; - -// TODO: make sure vowel harmonization is correct -const had = ['ri']; - -// TODO: make sure that this precedes an a or e -const will = ['ga-']; - -// TODO: make sure that this precedes a stative prefix -const shallBe = ['ga ']; - -// TODO: make sure that this precedes a stative prefix -const hasBeenAndStill = ['ka ']; - -const isRootVerb = (root: string, wordData: WordData): MinimizedWord | undefined => - (wordData.verbs || []).find( - ({ word: headword, definitions = [] }) => - definitions.find( - ({ wordClass: nestedWordClassEnum }) => - nestedWordClassEnum === WordClassEnum.AV || nestedWordClassEnum === WordClassEnum.PV - ) && - (removeAccents.removeExcluding(headword).normalize('NFC') === root || - definitions.find(({ nsibidi }) => nsibidi === root)) - ); - -const isSuffix = (root: string, wordData: WordData): MinimizedWord | undefined => - (wordData.suffixes || []).find( - ({ word: headword, definitions = [] }) => - definitions.find( - ({ wordClass }) => wordClass === WordClassEnum.ESUF || wordClass === WordClassEnum.ISUF - ) && - (removeAccents.removeExcluding(headword).replace('-', '').normalize('NFC') === root || - definitions.find(({ nsibidi }) => nsibidi && nsibidi.replace('-', '') === root)) - ); - -const topSolutions: TopSolution[] = []; - -const helper = ( - word: string, - wordData: WordData, - firstPointer: number, - secondPointer: number, - topSolution: Solution[], - meta: Meta -): Solution[] => { - let localSecondPoint = secondPointer; - const updatedMeta = { ...meta }; - updatedMeta.depth += 1; - const solutions = topSolution; - while (localSecondPoint <= word.length) { - const solution = [...topSolution]; - const currentRange = (word.substring(firstPointer, localSecondPoint) || '').trim(); - if (!currentRange) { - localSecondPoint += 1; - } else { - if (prefixes.includes(currentRange)) { - solution.push({ - type: PartTypes.INFINITIVE, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (!updatedMeta.isPreviousVerb && isRootVerb(currentRange, wordData)) { - solution.push({ - type: PartTypes.VERB_ROOT, - text: currentRange, - wordInfo: isRootVerb(currentRange, wordData), - wordClass: [WordClassEnum.AV, WordClassEnum.PV], - }); - updatedMeta.isPreviousVerb = true; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if ( - !updatedMeta.isPreviousStativePrefix && - !updatedMeta.isPreviousVerb && - negativePrefixes.includes(currentRange) - ) { - if (negativePrefixes.includes(currentRange)) { - const forkedUpdatedMeta = { ...updatedMeta }; - // Skipping to avoid matching with individual letters - // solution.push({ - // type: PartTypes.NEGATOR_PREFIX, - // text: currentRange, - // wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - // }); - // forkedUpdatedMeta.isPreviousVerb = false; - // forkedUpdatedMeta.isNegatorPrefixed = true; - // forkedUpdatedMeta.negativePrefix = currentRange; - solutions.push( - ...helper( - word, - wordData, - localSecondPoint, - localSecondPoint + 1, - solution, - forkedUpdatedMeta - ) - ); - } - } else if (updatedMeta.isPreviousStativePrefix && stativePairs.includes(currentRange)) { - solution.push({ - type: PartTypes.STATIVE_PREFIX_PAIR, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousStativePrefix = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if ( - negativesOrPast.includes(currentRange) && - negativePrefixes.includes(currentRange[currentRange.length - 1]) - ) { - solution.push({ - type: updatedMeta.isNegatorPrefixed ? PartTypes.NEGATOR : PartTypes.QUALIFIER_OR_PAST, - text: currentRange, - wordClass: [WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = !updatedMeta.isNegatorPrefixed; - updatedMeta.negativePrefix = ''; - updatedMeta.isNegatorPrefixed = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (suffixes.includes(currentRange)) { - solution.push({ - type: PartTypes.STATIVE, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (rv.includes(currentRange)) { - solution.push({ - type: PartTypes.QUALIFIER_OR_PAST, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (imperatives.includes(currentRange)) { - solution.push({ - type: PartTypes.IMPERATIVE, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = true; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (isSuffix(currentRange, wordData)) { - solution.push({ - type: PartTypes.EXTENSIONAL_SUFFIX, - text: currentRange, - wordInfo: isSuffix(currentRange, wordData), - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = true; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (multiplePeople.includes(currentRange)) { - // TODO: could be other meaning - solution.push({ - type: PartTypes.MULTIPLE_PEOPLE, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (stativePrefixes.includes(currentRange)) { - solution.push({ - type: PartTypes.STATIVE_PREFIX, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - updatedMeta.isPreviousStativePrefix = true; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (nots.includes(currentRange)) { - solution.push({ - type: PartTypes.NEGATIVE, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (hasNotYet.includes(currentRange)) { - solution.push({ - type: PartTypes.NEGATIVE_POTENTIAL, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (had.includes(currentRange)) { - solution.push({ - type: PartTypes.PERFECT_PAST, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (shallBe.includes(currentRange)) { - solution.push({ - type: PartTypes.POTENTIAL_CONTINUOUS_PREFIX, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (will.includes(currentRange)) { - solution.push({ - type: PartTypes.FUTURE_CONTINUOUS, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } else if (hasBeenAndStill.includes(currentRange)) { - solution.push({ - type: PartTypes.PAST_PERFECT_CONTINUOUS_PREFIX, - text: currentRange, - wordClass: [WordClassEnum.ISUF, WordClassEnum.ESUF], - }); - updatedMeta.isPreviousVerb = false; - solutions.push( - ...helper(word, wordData, localSecondPoint, localSecondPoint + 1, solution, updatedMeta) - ); - } - localSecondPoint += 1; - } - } - solutions.forEach((s) => { - if (s && !topSolutions.find(({ solution: currentSolution }) => isEqual(s, currentSolution))) { - topSolutions.push({ solution: s, metaData: updatedMeta }); - } - }); - return solutions || []; -}; - -// Backtracking TODO: tighten rules -export default (rawWord: string, wordData: WordData): Solution[] => { - const word = rawWord.toLowerCase().normalize('NFC'); - const firstPointer = 0; - const secondPointer = 1; - const allWords = Object.values(wordData).flat(); - const verbs = allWords.filter(({ definitions = [] }) => - definitions.find( - ({ wordClass: nestedWordClassEnum }) => - nestedWordClassEnum === WordClassEnum.AV || nestedWordClassEnum === WordClassEnum.PV - ) - ); - const wordSuffixes = allWords.filter(({ definitions = [] }) => - definitions.find( - ({ wordClass: nestedWordClassEnum }) => - nestedWordClassEnum === WordClassEnum.ESUF || nestedWordClassEnum === WordClassEnum.ISUF - ) - ); - helper(word, { verbs, suffixes: wordSuffixes }, firstPointer, secondPointer, [], { depth: 0 }); - const finalSolution = - compact( - topSolutions.map(({ solution, metaData }) => { - const cleanedText = removeAccents - .removeExcluding( - [...solution.text].reduce((finalString, letter) => `${finalString}${letter}`, '') || '' - ) - .normalize('NFC'); - - // TODO: requiring matching parts to be complete set - if (cleanedText === word && !metaData.isNegatorPrefixed) { - console.log('Cleaned text and word matched and does not have a negator prefix'); - } - return solution; - }) - ) || []; - return finalSolution; -}; diff --git a/src/controllers/utils/index.ts b/src/controllers/utils/index.ts index 68eb196b..1eab75a0 100644 --- a/src/controllers/utils/index.ts +++ b/src/controllers/utils/index.ts @@ -15,19 +15,19 @@ const createSimpleRegExp = (keywords: { text: string }[]) => ({ `${keywords .map((keyword) => `(${createRegExp(keyword.text, true).wordReg.source})`) .join('|')}`, - 'i', + 'i' ), exampleReg: new RegExp( `${keywords .map((keyword) => `(${createRegExp(keyword.text, true).exampleReg.source})`) .join('|')}`, - 'i', + 'i' ), definitionsReg: new RegExp( `${keywords .map((keyword) => `(${createRegExp(keyword.text, true).definitionsReg.source})`) .join('|')}`, - 'i', + 'i' ), hardDefinitionsReg: new RegExp( `${keywords @@ -35,10 +35,10 @@ const createSimpleRegExp = (keywords: { text: string }[]) => ({ (keyword) => `(${ (createRegExp(keyword.text, true).hardDefinitionsReg || { source: keyword.text }).source - })`, + })` ) .join('|')}`, - 'i', + 'i' ), }); @@ -93,7 +93,7 @@ const convertFilterToKeyword = (filter = '{"word": ""}') => { return parsedFilter[firstFilterKey]; } catch { throw new Error( - `Invalid filter query syntax. Expected: {"word":"filter"}, Received: ${filter}`, + `Invalid filter query syntax. Expected: {"word":"filter"}, Received: ${filter}` ); } }; @@ -125,7 +125,6 @@ export const handleQueries = async ({ const version = baseUrl.endsWith(Version.VERSION_2) ? Version.VERSION_2 : Version.VERSION_1; const filter = convertFilterToKeyword(filterQuery); const searchWord = removePrefix(keyword || filter || '').replace(/[Aa]na m /, 'm '); - // const searchWordParts = compact(searchWord.split(' ')); const regex = constructRegexQuery({ isUsingMainKey, keywords: [{ text: searchWord }] }); const keywords: Keyword[] = []; const page = parseInt(pageQuery, 10); diff --git a/src/controllers/utils/queries.ts b/src/controllers/utils/queries.ts index 101d07c1..a3258b2a 100644 --- a/src/controllers/utils/queries.ts +++ b/src/controllers/utils/queries.ts @@ -1,6 +1,5 @@ import compact from 'lodash/compact'; import { cjkRange } from '../../shared/constants/diacriticCodes'; -import WordClass from '../../shared/constants/WordClass'; import Tenses from '../../shared/constants/Tenses'; import StopWords from '../../shared/constants/StopWords'; import { Flags, Keyword } from './types'; @@ -137,11 +136,6 @@ export const searchEnglishRegexQuery = definitionsQuery; export const searchForAllDevelopers = () => ({ name: { $ne: '' }, }); -export const searchForAllVerbsAndSuffixesQuery = () => ({ - 'definitions.wordClass': { - $in: [WordClass.AV.value, WordClass.PV.value, WordClass.ISUF.value, WordClass.ESUF.value], - }, -}); export const searchNsibidiCharactersQuery = (keyword: string) => { const regex = createRegExp(keyword).wordReg; return { From 1e9e55b19ee6522e710b4b15e539b454f34c8cd8 Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Thu, 29 Aug 2024 11:53:03 -0700 Subject: [PATCH 7/8] test: correct axios mock --- __mocks__/axios.ts | 4 +++- src/controllers/__tests__/speechToText.test.ts | 6 ++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/__mocks__/axios.ts b/__mocks__/axios.ts index e7227c55..1dff8b3a 100644 --- a/__mocks__/axios.ts +++ b/__mocks__/axios.ts @@ -1,4 +1,6 @@ export const mockRequest = jest.fn((config) => config); export const request = jest.fn(); +// @ts-expect-error +mockRequest.request = request; -export default { default: mockRequest, request }; +export default mockRequest; diff --git a/src/controllers/__tests__/speechToText.test.ts b/src/controllers/__tests__/speechToText.test.ts index ca019851..381409b7 100644 --- a/src/controllers/__tests__/speechToText.test.ts +++ b/src/controllers/__tests__/speechToText.test.ts @@ -18,8 +18,7 @@ describe('speechToText', () => { const base64 = 'base64'; // @ts-expect-error fetchBase64Data.mockResolvedValue(base64); - // @ts-expect-error - axios.request.mockResolvedValue({ + jest.spyOn(axios, 'request').mockResolvedValue({ data: { Key: '/audioId.com/', Location: 'https://igboapi.com' }, }); await getTranscription(req, res, next); @@ -54,8 +53,7 @@ describe('speechToText', () => { const base64 = 'base64'; // @ts-expect-error fetchBase64Data.mockResolvedValue(base64); - // @ts-expect-error - axios.request.mockResolvedValue({}); + jest.spyOn(axios, 'request').mockResolvedValue({}); await getTranscription(req, res, next); expect(axios.request).not.toHaveBeenCalledWith({ method: 'POST', From a15d3d52d7a834cd9b5112df61043e563af9b9c7 Mon Sep 17 00:00:00 2001 From: Ijemma Onwuzulike Date: Thu, 29 Aug 2024 16:17:33 -0700 Subject: [PATCH 8/8] feat: support base64 for speech-to-text --- src/config.ts | 5 ++ .../__tests__/speechToText.test.ts | 52 ++++++++++++++++--- src/controllers/speechToText.ts | 44 ++++++++++------ 3 files changed, 78 insertions(+), 23 deletions(-) diff --git a/src/config.ts b/src/config.ts index 95f7e65a..beb8382f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -101,6 +101,11 @@ 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 = diff --git a/src/controllers/__tests__/speechToText.test.ts b/src/controllers/__tests__/speechToText.test.ts index 381409b7..f8e20c54 100644 --- a/src/controllers/__tests__/speechToText.test.ts +++ b/src/controllers/__tests__/speechToText.test.ts @@ -11,7 +11,10 @@ jest.mock('axios'); jest.mock('../utils/fetchBase64Data'); describe('speechToText', () => { - it('calls audio IgboSpeech audio endpoint to upload audio', async () => { + 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(); @@ -19,24 +22,59 @@ describe('speechToText', () => { // @ts-expect-error fetchBase64Data.mockResolvedValue(base64); jest.spyOn(axios, 'request').mockResolvedValue({ - data: { Key: '/audioId.com/', Location: 'https://igboapi.com' }, + 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: 'https://speech.igboapi.com/audio', + 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: 'https://speech.igboapi.com/predict', + url: 'http://localhost:3333/predict', headers: { 'Content-Type': 'application/json', + 'X-API-Key': 'main_key', }, data: { id: 'audioId', url: 'https://igboapi.com' }, }); @@ -57,17 +95,19 @@ describe('speechToText', () => { await getTranscription(req, res, next); expect(axios.request).not.toHaveBeenCalledWith({ method: 'POST', - url: 'https://speech.igboapi.com/audio', + url: 'http://localhost:3333/audio', headers: { 'Content-Type': 'application/json', + 'X-API-Key': 'main_key', }, data: { base64 }, }); expect(axios.request).toHaveBeenCalledWith({ method: 'POST', - url: 'https://speech.igboapi.com/predict', + url: 'http://localhost:3333/predict', headers: { 'Content-Type': 'application/json', + 'X-API-Key': 'main_key', }, data: { id: 'audioId', url: audioUrl }, }); diff --git a/src/controllers/speechToText.ts b/src/controllers/speechToText.ts index 930c06dd..e48da591 100644 --- a/src/controllers/speechToText.ts +++ b/src/controllers/speechToText.ts @@ -1,9 +1,13 @@ import axios from 'axios'; import { MiddleWare } from '../types'; import { fetchBase64Data } from './utils/fetchBase64Data'; -import { parseAWSIdFromKey, parseAWSIdFromUri } from './utils/parseAWS'; +import { parseAWSIdFromUri } from './utils/parseAWS'; +import { MAIN_KEY, SPEECH_TO_TEXT_API } from '../config'; -const SPEECH_TO_TEXT_API = 'https://speech.igboapi.com'; +interface AudioMetadata { + audioId: string; + audioUrl: string; +} interface Prediction { transcription: string; @@ -19,26 +23,31 @@ interface Prediction { export const getTranscription: MiddleWare = async (req, res, next) => { try { const { audioUrl: audio } = req.body; - if (!audio.startsWith('https://')) { - throw new Error('Audio URL must be hosted publicly.'); + 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 = await fetchBase64Data(audio); + 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({ - method: 'POST', - url: `${SPEECH_TO_TEXT_API}/audio`, - headers: { - 'Content-Type': 'application/json', - }, - data: { base64 }, - }); - - const audioId = parseAWSIdFromKey(response.Key); - payload = { id: audioId, url: response.Location }; + const { data: response } = await axios + .request({ + 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 }; @@ -50,11 +59,12 @@ export const getTranscription: MiddleWare = async (req, res, next) => { url: `${SPEECH_TO_TEXT_API}/predict`, headers: { 'Content-Type': 'application/json', + 'X-API-Key': MAIN_KEY, }, data: payload, }); - return res.send(response); + return res.send({ transcription: response.transcription }); } catch (err) { return next(); }