Skip to content

Commit

Permalink
Add translation endpoint to Igbo API (#812)
Browse files Browse the repository at this point in the history
* feat: add config for translation model endpoint

* feat: add translation middleware and validation

* feat: add translation endpoint and ratelimiter to new route

* fix: controller import

* refactor: fix org name in comment

* feat: add translate enums

* fix: address comments

* feat: add api type route map and extraction helper

* refactor: rename translation endpoint

* chore: remove unused import

* test: add translation endpoint tests

* Update src/routers/routerV2.ts

Co-authored-by: Ijemma Onwuzulike <[email protected]>

* chore: add comment to translation max constant

* refactor: remove rate limiting for now

* chore: address pr comments

* feat: add zod-validation-error package for formatting api errors

* refactor: update schema for translation

* test: add new tests for new translation validation

* feat: validate extra keys not allowed

---------

Co-authored-by: Ijemma Onwuzulike <[email protected]>
  • Loading branch information
ebubae and ijemmao authored Nov 1, 2024
1 parent 714df73 commit 0ac1b9d
Show file tree
Hide file tree
Showing 11 changed files with 509 additions and 405 deletions.
677 changes: 279 additions & 398 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@
"tailwindcss": "3",
"typescript": "^5.6.2",
"unicharadata": "^9.0.0-alpha.6",
"uuid": "^8.3.2"
"uuid": "^8.3.2",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@commitlint/cli": "^11.0.0",
Expand Down
7 changes: 5 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const SENDGRID_NEW_DEVELOPER_ACCOUNT_TEMPLATE_SOURCE = defineString(
// Igbo API
const ENV_MAIN_KEY = defineString('ENV_MAIN_KEY').value();

// Nkọwa okwu AI Models
const ENV_IGBO_TO_ENGLISH_URL = defineString('ENV_IGBO_TO_ENGLISH_URL').value();

// Google Analytics
const ANALYTICS_GA_TRACKING_ID = defineString('ANALYTICS_GA_TRACKING_ID').value();
const ANALYTICS_GA_API_SECRET = defineString('ANALYTICS_GA_API_SECRET').value();
Expand Down Expand Up @@ -101,11 +104,11 @@ export const CORS_CONFIG = {
export const API_ROUTE = isProduction ? '' : `http://localhost:${PORT}`;
export const API_DOCS = 'https://docs.igboapi.com';

// IgboSpeech
// Nkọwa okwu AI Models
export const SPEECH_TO_TEXT_API = isProduction
? 'https://speech.igboapi.com'
: 'http://localhost:3333';

export const IGBO_TO_ENGLISH_API = ENV_IGBO_TO_ENGLISH_URL;
// SendGrid API
export const SENDGRID_API_KEY = SENDGRID_API_KEY_SOURCE || '';
export const SENDGRID_NEW_DEVELOPER_ACCOUNT_TEMPLATE =
Expand Down
1 change: 0 additions & 1 deletion src/controllers/__tests__/examples.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { incomingExampleFixture } from '../../__tests__/shared/fixtures';
import LanguageEnum from '../../shared/constants/LanguageEnum';
import { SuggestionSourceEnum } from '../../shared/constants/SuggestionSourceEnum';
import { convertToV1Example } from '../examples';

describe('examples', () => {
Expand Down
101 changes: 101 additions & 0 deletions src/controllers/__tests__/translation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import axios from 'axios';
import {
requestFixture,
responseFixture,
nextFunctionFixture,
} from '../../__tests__/shared/fixtures';
import { MAIN_KEY } from '../../../__tests__/shared/constants';
import { getTranslation } from '../translation';

describe('translation', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('calls Nkọwa okwu ML translation server', async () => {
const req = requestFixture({
body: { text: 'aka', sourceLanguageCode: 'ibo', destinationLanguageCode: 'eng' },
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAIN_KEY,
},
});
const res = responseFixture();
const next = nextFunctionFixture();
jest.spyOn(axios, 'request').mockResolvedValue({
data: { text: 'aka', sourceLanguageCode: 'ibo', destinationLanguageCode: 'eng' },
});
await getTranslation(req, res, next);
expect(res.send).toHaveBeenCalled();
});

it('throws validation error when input is too long', async () => {
const req = requestFixture({
body: { text: 'aka'.repeat(100), sourceLanguageCode: 'ibo', destinationLanguageCode: 'eng' },
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAIN_KEY,
},
});
const res = responseFixture();
const next = nextFunctionFixture();
jest.spyOn(axios, 'request').mockResolvedValue({
data: { text: 'aka'.repeat(100), sourceLanguageCode: 'ibo', destinationLanguageCode: 'eng' },
});
await getTranslation(req, res, next);
expect(next).toHaveBeenCalledWith(
new Error('Cannot translate text greater than 120 characters')
);
});
it('throws validation error when input string is empty', async () => {
const req = requestFixture({
body: { text: '', sourceLanguageCode: 'ibo', destinationLanguageCode: 'eng' },
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAIN_KEY,
},
});
const res = responseFixture();
const next = nextFunctionFixture();
jest.spyOn(axios, 'request').mockResolvedValue({
data: { text: '', sourceLanguageCode: 'ibo', destinationLanguageCode: 'eng' },
});
await getTranslation(req, res, next);
expect(next).toHaveBeenCalledWith(new Error('Cannot translate empty string'));
});
it('throws validation error for unsupported language combinations', async () => {
const req = requestFixture({
body: { text: 'aka', sourceLanguageCode: 'ibo', destinationLanguageCode: 'hau' },
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAIN_KEY,
},
});
const res = responseFixture();
const next = nextFunctionFixture();
jest.spyOn(axios, 'request').mockResolvedValue({
data: { text: 'aka', sourceLanguageCode: 'ibo', destinationLanguageCode: 'hau' },
});
await getTranslation(req, res, next);
expect(next).toHaveBeenCalledWith(new Error('ibo to hau translation is not yet supported'));
});
it('throws validation error for missing keys', async () => {
const req = requestFixture({
body: { text: 'aka' },
headers: {
'Content-Type': 'application/json',
'X-API-Key': MAIN_KEY,
},
});
const res = responseFixture();
const next = nextFunctionFixture();
jest.spyOn(axios, 'request').mockResolvedValue({
data: { text: 'aka' },
});
await getTranslation(req, res, next);
expect(next).toHaveBeenCalledWith(
new Error(
'Validation error: Required at "sourceLanguageCode"; Required at "destinationLanguageCode"'
)
);
});
});
80 changes: 80 additions & 0 deletions src/controllers/translation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import axios from 'axios';
import { MiddleWare } from '../types';
import { IGBO_TO_ENGLISH_API, MAIN_KEY } from '../config';
import { z } from 'zod';
import { fromError } from 'zod-validation-error';
import LanguageEnum from '../shared/constants/LanguageEnum';

interface IgboEnglishTranslationMetadata {
igbo: string;
}

const TranslationRequestBody = z
.object({
text: z.string(),
sourceLanguageCode: z.nativeEnum(LanguageEnum),
destinationLanguageCode: z.nativeEnum(LanguageEnum),
})
.strict();

interface Translation {
translation: string;
}

// Due to limit on inputs used to train the model, the maximum
// Igbo translation input is 120 characters
const IGBO_ENGLISH_TRANSLATION_INPUT_MAX_LENGTH = 120;

/**
* Talks to Igbo-to-English translation model to translate the provided text.
* @param req
* @param res
* @param next
* @returns English text translation of the provided Igbo text
*/
export const getTranslation: MiddleWare = async (req, res, next) => {
try {
const requestBodyValidation = TranslationRequestBody.safeParse(req.body);
if (!requestBodyValidation.success) {
throw fromError(requestBodyValidation.error);
}
const requestBody = requestBodyValidation.data;

if (requestBody.sourceLanguageCode === requestBody.destinationLanguageCode) {
throw new Error('Source and destination languages must be different');
}
if (
requestBody.sourceLanguageCode !== LanguageEnum.IGBO ||
requestBody.destinationLanguageCode !== LanguageEnum.ENGLISH
) {
throw new Error(
`${requestBody.sourceLanguageCode} to ${requestBody.destinationLanguageCode} translation is not yet supported`
);
}
const igboText = requestBody.text;
if (!igboText) {
throw new Error('Cannot translate empty string');
}

if (igboText.length > IGBO_ENGLISH_TRANSLATION_INPUT_MAX_LENGTH) {
throw new Error('Cannot translate text greater than 120 characters');
}

const payload: IgboEnglishTranslationMetadata = { igbo: igboText };

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

return res.send({ translation: response.translation });
} catch (err) {
return next(err);
}
};
33 changes: 30 additions & 3 deletions src/middleware/helpers/authorizeDeveloperUsage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { findDeveloperUsage } from './findDeveloperUsage';
import { createDeveloperUsage } from './createDeveloperUsage';
import { DeveloperUsageDocument } from '../../types/developerUsage';
import ApiType from '../../shared/constants/ApiType';
import ApiTypeToRoute from '../../shared/constants/ApiTypeToRoute';
import ApiUsageLimit from '../../shared/constants/ApiUsageLimit';
import { DeveloperDocument } from '../../types';

Expand Down Expand Up @@ -58,8 +59,34 @@ export const authorizeDeveloperUsage = async ({
}: {
path: string,
developer: DeveloperDocument,
}) =>
handleDeveloperUsage({
}) => {
const extractedPath = getPath(path);
return handleDeveloperUsage({
developer,
apiType: path.startsWith('speech-to-text') ? ApiType.SPEECH_TO_TEXT : ApiType.DICTIONARY,
apiType: getApiTypeFromRoute(extractedPath),
});
};

/**
* The function maps route strings to the APIType Enum that would represent usage
* of the route
* @param route The string name of the route
* @returns The ApiType of the route passed as input. For example /speech-to-text would
* route to the Speech-to-Text API type
*/
const getApiTypeFromRoute = (route: string): ApiType => {
switch (route) {
case ApiTypeToRoute.SPEECH_TO_TEXT:
return ApiType.SPEECH_TO_TEXT;
case ApiTypeToRoute.TRANSLATE:
return ApiType.TRANSLATE;
default:
return ApiType.DICTIONARY;
}
};

const getPath = (path: string) => {
// Extract router path from full path
// ex. speech-to-text/params=param -> speech-to-text
return path.split(/[\/\?]/)[0];
};
2 changes: 2 additions & 0 deletions src/routers/routerV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getWords, getWord } from '../controllers/words';
import { getExample, getExamples } from '../controllers/examples';
import { getNsibidiCharacter, getNsibidiCharacters } from '../controllers/nsibidi';
import { getTranscription } from '../controllers/speechToText';
import { getTranslation } from '../controllers/translation';
import validId from '../middleware/validId';
import validateApiKey from '../middleware/validateApiKey';
import analytics from '../middleware/analytics';
Expand All @@ -26,6 +27,7 @@ routerV2.get(

// Speech-to-Text
routerV2.post('/speech-to-text', analytics, validateApiKey, getTranscription);
routerV2.post('/translate', analytics, validateApiKey, getTranslation);

// Redirects to V1
routerV2.post('/developers', (_, res) => res.redirect('/api/v1/developers'));
Expand Down
1 change: 1 addition & 0 deletions src/shared/constants/ApiType.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
enum ApiType {
DICTIONARY = 'DICTIONARY',
SPEECH_TO_TEXT = 'SPEECH_TO_TEXT',
TRANSLATE = 'TRANSLATE',
}

export default ApiType;
8 changes: 8 additions & 0 deletions src/shared/constants/ApiTypeToRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import ApiType from './ApiType';

const ApiTypeToRoute = {
[ApiType.SPEECH_TO_TEXT]: 'speech-to-text',
[ApiType.TRANSLATE]: 'translate',
};

export default ApiTypeToRoute;
1 change: 1 addition & 0 deletions src/shared/constants/ApiUsageLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import ApiType from './ApiType';
const ApiUsageLimit = {
[ApiType.DICTIONARY]: 2500,
[ApiType.SPEECH_TO_TEXT]: 20,
[ApiType.TRANSLATE]: 5,
};

export default ApiUsageLimit;

0 comments on commit 0ac1b9d

Please sign in to comment.