Add language endpoint to developer APIs (#41)

* Added languages endpoint and its tests
This commit is contained in:
David
2021-08-30 21:35:22 +02:00
committed by GitHub
parent 1b6c324560
commit 870ec2db64
11 changed files with 360 additions and 74 deletions

View File

@@ -58,16 +58,33 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs:
+ GET `/api/v1/:source/:target/:query` + GET `/api/v1/:source/:target/:query`
```typescript ```typescript
{ {
translation?: string, translation: string
errorMsg?: string
} }
``` ```
+ GET `/api/v1/audio/:lang/:query` + GET `/api/v1/audio/:lang/:query`
```typescript ```typescript
{ {
audio?: number[], audio: number[]
errorMsg?: string }
```
+ GET `/api/v1/languages/?:(source|target)`
```typescript
{
languages: [
{
code: string,
name: string
}
]
}
```
In addition, every endpoint can return an error message with the following structure instead.
```typescript
{
error: string
} }
``` ```
@@ -78,20 +95,33 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs:
query { query {
translation(source: String target: String query: String!) { translation(source: String target: String query: String!) {
source: { source: {
lang: String! lang: {
text: String code: String!
audio: [Int] name: String!
}
text: String!
audio: [Int]!
} }
target: { target: {
lang: String! lang: {
text: String code: String!
audio: [Int] name: String!
}
text: String!
audio: [Int]!
} }
} }
audio(lang: String! query: String!) { audio(lang: String! query: String!) {
lang: String! lang: {
text: String code: String!
audio: [Int] name: String!
}
text: String!
audio: [Int]!
}
languages(type: SOURCE|TARGET) {
code: String!
name: String!
} }
} }
``` ```

View File

@@ -9,7 +9,7 @@ import { useHotkeys } from "react-hotkeys-hook";
import { CustomHead, LangSelect, TranslationArea } from "@components"; import { CustomHead, LangSelect, TranslationArea } from "@components";
import { useToastOnLoad } from "@hooks"; import { useToastOnLoad } from "@hooks";
import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate"; import { googleScrape, extractSlug, textToSpeechScrape } from "@utils/translate";
import { retrieveFiltered, replaceBoth } from "@utils/language"; import { retrieveFromType, replaceBoth } from "@utils/language";
import langReducer, { Actions, initialState } from "@utils/reducer"; import langReducer, { Actions, initialState } from "@utils/reducer";
const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false }); const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false });
@@ -66,7 +66,8 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
return () => Router.events.off("beforeHistoryChange", handler); return () => Router.events.off("beforeHistoryChange", handler);
}, []); }, []);
const { sourceLangs, targetLangs } = retrieveFiltered(); const sourceLangs = retrieveFromType("source");
const targetLangs = retrieveFromType("target");
const { source: transLang, target: queryLang } = replaceBoth("exception", { source: target, target: source }); const { source: transLang, target: queryLang } = replaceBoth("exception", { source: target, target: source });
useToastOnLoad({ useToastOnLoad({
@@ -191,7 +192,9 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
const [sourceAudio, targetAudio] = await Promise.all([ const [sourceAudio, targetAudio] = await Promise.all([
textToSpeechScrape(source, query), textToSpeechScrape(source, query),
textToSpeechScrape(target, textScrape.translationRes) "translationRes" in textScrape
? textToSpeechScrape(target, textScrape.translationRes)
: null
]); ]);
return { return {
@@ -205,7 +208,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
source, target, query source, target, query
} }
}, },
revalidate: !textScrape.errorMsg revalidate: !("errorMsg" in textScrape)
? 2 * 30 * 24 * 60 * 60 // 2 months ? 2 * 30 * 24 * 60 * 60 // 2 months
: 1 : 1
}; };

View File

@@ -1,21 +1,31 @@
import { ApolloServer, gql, IResolvers } from "apollo-server-micro"; import { ApolloServer, gql, IResolvers, ApolloError, UserInputError } from "apollo-server-micro";
import { NextApiHandler } from "next"; import { NextApiHandler } from "next";
import NextCors from "nextjs-cors"; import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "@utils/translate"; import { googleScrape, textToSpeechScrape } from "@utils/translate";
import { retrieveFromType, getName } from "@utils/language";
export const typeDefs = gql` export const typeDefs = gql`
enum LangType {
SOURCE,
TARGET
}
type Query { type Query {
translation(source: String="auto" target: String="en" query: String!): Translation! translation(source: String="auto" target: String="en" query: String!): Translation!
audio(lang: String! query: String!): Entry! audio(lang: String! query: String!): Entry!
languages(type: LangType): [Language]!
} }
type Translation { type Translation {
source: Entry! source: Entry!
target: Entry! target: Entry!
} }
type Entry { type Entry {
lang: String! lang: Language!
text: String text: String!
audio: [Int] audio: [Int]!
}
type Language {
code: String!
name: String!
} }
`; `;
@@ -25,35 +35,65 @@ export const resolvers: IResolvers = {
const { source, target, query } = args; const { source, target, query } = args;
return { return {
source: { source: {
lang: source, lang: {
code: source
},
text: query text: query
}, },
target: { target: {
lang: target lang: {
code: target
}
} }
}; };
}, },
audio(_, args) { audio(_, args) {
const { lang, query } = args;
return { return {
lang: args.lang, lang: {
text: args.query code: lang
},
text: query
}; };
},
languages(_, args) {
const { type } = args;
const langEntries = retrieveFromType(type?.toLocaleLowerCase());
return langEntries.map(([code, name]) => ({ code, name }));
} }
}, },
Translation: { Translation: {
async target(parent) { async target(parent) {
const { source, target } = parent; const { source, target } = parent;
const { translationRes } = await googleScrape(source.lang, target.lang, source.text); const textScrape = await googleScrape(source.lang.code, target.lang.code, source.text);
if ("errorMsg" in textScrape)
throw new ApolloError(textScrape.errorMsg);
return { return {
lang: target.lang, lang: target.lang,
text: translationRes text: textScrape.translationRes
}; };
} }
}, },
Entry: { Entry: {
async audio(parent) { async audio(parent) {
const { lang, text } = parent; const { lang, text } = parent;
return await textToSpeechScrape(lang, text); const audio = await textToSpeechScrape(lang.code, text);
if (!audio)
throw new ApolloError("An error occurred while retrieving the audio");
return audio;
}
},
Language: {
name(parent) {
const { code, name } = parent;
if (name)
return name;
const newName = getName(code);
if (!newName)
throw new UserInputError("Invalid language code");
return newName;
} }
} }
}; };

View File

@@ -3,9 +3,11 @@ import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "@utils/translate"; import { googleScrape, textToSpeechScrape } from "@utils/translate";
type Data = { type Data = {
translation?: string, translation: string,
audio?: number[], } | {
error?: string audio: number[]
} | {
error: string
}; };
const methods = ["GET"]; const methods = ["GET"];
@@ -39,11 +41,11 @@ const handler: NextApiHandler<Data> = async (req, res) => {
: res.status(500).json({ error: "An error occurred while retrieving the audio" }); : res.status(500).json({ error: "An error occurred while retrieving the audio" });
} }
const { translationRes, errorMsg } = await googleScrape(source, target, query); const textScrape = await googleScrape(source, target, query);
if (errorMsg) if ("errorMsg" in textScrape)
return res.status(500).json({ error: errorMsg }); return res.status(500).json({ error: textScrape.errorMsg });
res.status(200).json({ translation: translationRes }); res.status(200).json({ translation: textScrape.translationRes });
} }
export default handler; export default handler;

View File

@@ -0,0 +1,47 @@
import { NextApiHandler } from "next";
import NextCors from "nextjs-cors";
import { retrieveFromType } from "@utils/language";
type Data = {
languages: {
code: string,
name: string
}[]
} | {
error: string
};
const methods = ["GET"];
const handler: NextApiHandler<Data> = async (req, res) => {
await NextCors(req, res, {
methods,
origin: "*",
preflightContinue: true
});
const {
query: { slug },
method
} = req;
if (slug && Array.isArray(slug) && slug.length > 1)
return res.status(404).json({ error: "Not Found" });
if (!method || !methods.includes(method)) {
res.setHeader("Allow", methods);
return res.status(405).json({ error: "Method Not Allowed" });
}
const type = slug?.[0];
if (type !== undefined && type !== "source" && type !== "target")
return res.status(400).json({ error: "Type should be 'source', 'target' or empty" });
const langEntries = retrieveFromType(type);
const languages = langEntries.map(([code, name]) => ({ code, name }));
res.status(200).json({ languages });
}
export default handler;

View File

@@ -58,7 +58,9 @@ it("returns audio triggering fetch", async () => {
query: ` query: `
query($lang: String! $text: String!) { query($lang: String! $text: String!) {
audio(lang: $lang query: $text) { audio(lang: $lang query: $text) {
lang lang {
code
}
text text
audio audio
} }
@@ -66,15 +68,15 @@ it("returns audio triggering fetch", async () => {
`, `,
variables: { lang, text } variables: { lang, text }
}); });
expect(data).toMatchObject({ audio: { lang, text, audio: expect.any(Array) } }); expect(data).toMatchObject({ audio: { lang: { code: lang }, text, audio: expect.any(Array) } });
expect(fetch).toHaveBeenCalledTimes(1); expect(fetch).toHaveBeenCalledTimes(1);
}); });
it("returns null on translation error", async () => { it("returns null and throws on translation error", async () => {
const text = faker.random.words(); const text = faker.random.words();
fetchMock.mockRejectOnce(); fetchMock.mockRejectOnce();
const { data } = await query({ const { data, errors } = await query({
query: ` query: `
query($text: String!) { query($text: String!) {
translation(query: $text) { translation(query: $text) {
@@ -86,15 +88,16 @@ it("returns null on translation error", async () => {
`, `,
variables: { text } variables: { text }
}); });
expect(data).toMatchObject({ translation: { target: { text: null } } }); expect(data).toBeNull();
expect(errors).toBeTruthy();
}); });
it("returns null on audio error", async () => { it("returns null and throws on audio error", async () => {
const lang = faker.random.locale(); const lang = faker.random.locale();
const text = faker.random.words(); const text = faker.random.words();
fetchMock.mockRejectOnce(); fetchMock.mockRejectOnce();
const { data } = await query({ const { data, errors } = await query({
query: ` query: `
query($lang: String! $text: String!) { query($lang: String! $text: String!) {
audio(lang: $lang query: $text) { audio(lang: $lang query: $text) {
@@ -104,29 +107,52 @@ it("returns null on audio error", async () => {
`, `,
variables: { lang, text } variables: { lang, text }
}); });
expect(data).toMatchObject({ audio: { audio: null } }); expect(data).toBeNull();
expect(errors).toBeTruthy();
}); });
it("keeps a default value for both source and target languages", async () => { it("keeps a default value for both source and target languages", async () => {
const text = faker.random.words(); const text = faker.random.words();
fetchMock.mockRejectOnce(); const translation = faker.random.words();
resolveFetchWith(htmlRes(translation));
const { data } = await query({ const { data } = await query({
query: ` query: `
query($text: String!) { query($text: String!) {
translation(query: $text) { translation(query: $text) {
source { source {
lang lang {
code
name
}
} }
target { target {
lang lang {
code
name
}
} }
} }
} }
`, `,
variables: { text } variables: { text }
}); });
expect(data).toMatchObject({ translation: { source: { lang: "auto" }, target: { lang: "en" } } }); expect(data).toMatchObject({
translation: {
source: {
lang: {
code: "auto",
name: "Detect"
}
},
target: {
lang: {
code: "en",
name: "English"
}
}
}
});
}); });
it("throws error on empty query in translation", async () => { it("throws error on empty query in translation", async () => {
@@ -135,10 +161,14 @@ it("throws error on empty query in translation", async () => {
query { query {
translation { translation {
source { source {
lang lang {
code
}
} }
target { target {
lang lang {
code
}
} }
} }
} }
@@ -155,7 +185,9 @@ it("throws error on empty lang or query in audio", async () => {
query: ` query: `
query($lang: String!) { query($lang: String!) {
audio(lang: $lang) { audio(lang: $lang) {
lang lang {
code
}
text text
} }
} }
@@ -168,7 +200,9 @@ it("throws error on empty lang or query in audio", async () => {
query: ` query: `
query($text: String!) { query($text: String!) {
audio(query: $text) { audio(query: $text) {
lang lang {
code
}
text text
} }
} }
@@ -177,3 +211,44 @@ it("throws error on empty lang or query in audio", async () => {
}); });
expect(langErrors).toBeTruthy(); expect(langErrors).toBeTruthy();
}); });
it("returns languages on empty type", async () => {
const { data } = await query({
query: `
query {
languages {
code
}
}
`
});
expect(data).toMatchObject({ languages: expect.any(Array) });
});
it("returns languages on 'source' type", async () => {
const { data } = await query({
query: `
query($type: LangType!) {
languages(type: $type) {
code
}
}
`,
variables: { type: "SOURCE" }
});
expect(data).toMatchObject({ languages: expect.any(Array) });
});
it("returns languages on 'target' type", async () => {
const { data } = await query({
query: `
query($type: LangType!) {
languages(type: $type) {
code
}
}
`,
variables: { type: "TARGET" }
});
expect(data).toMatchObject({ languages: expect.any(Array) });
});

View File

@@ -0,0 +1,65 @@
import httpMocks from "node-mocks-http";
import handler from "@pages/api/v1/languages/[[...slug]]";
it("returns 404 on >1 params", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["one", "two"] }
});
await handler(req, res);
expect(res.statusCode).toBe(404);
});
it("returns 405 on forbidden method", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "POST",
query: {}
});
await handler(req, res);
expect(res.statusCode).toBe(405);
});
it("returns 400 on wrong param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["other"] }
});
await handler(req, res);
expect(res.statusCode).toBe(400);
});
it("returns 200 on empty param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: {}
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ languages: expect.any(Array) });
});
it("returns 200 on 'source' param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["source"] }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ languages: expect.any(Array) });
});
it("returns 200 on 'target' param", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["target"] }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ languages: expect.any(Array) });
});

View File

@@ -1,4 +1,5 @@
import { replaceBoth, retrieveFiltered, CheckType, LangType } from "@utils/language"; import faker from "faker";
import { replaceBoth, retrieveFromType, getName, CheckType, LangType } from "@utils/language";
import { languages, exceptions, mappings } from "@utils/languages.json"; import { languages, exceptions, mappings } from "@utils/languages.json";
describe("replaceBoth", () => { describe("replaceBoth", () => {
@@ -34,14 +35,34 @@ describe("replaceBoth", () => {
}); });
}); });
describe("retrieveFiltered", () => { describe("retrieveFromType", () => {
const filteredEntries = (langType: LangType) => ( const checkExceptions = (langType: LangType) => (
Object.entries(languages).filter(([code]) => !Object.keys(exceptions[langType]).includes(code)) retrieveFromType(langType).forEach(([code]) => !Object.keys(exceptions).includes(code))
); );
it("filters by exceptions", () => { it("returns full list on empty type", () => {
const { sourceLangs, targetLangs } = retrieveFiltered(); expect(retrieveFromType()).toStrictEqual(Object.entries(languages));
expect(sourceLangs).toStrictEqual(filteredEntries("source")); });
expect(targetLangs).toStrictEqual(filteredEntries("target"));
it("filters source exceptions", () => {
checkExceptions("source");
});
it("filters target exceptions", () => {
checkExceptions("target");
});
});
describe("getName", () => {
it("returns name from valid code", () => {
const langEntries = Object.entries(languages);
const randomEntry = faker.random.arrayElement(langEntries);
const [code, name] = randomEntry;
expect(getName(code)).toEqual(name);
});
it("returns null on wrong code", () => {
const randomCode = faker.random.words();
expect(getName(randomCode)).toBeNull();
}); });
}); });

View File

@@ -24,14 +24,14 @@ describe("googleScrape", () => {
resolveFetchWith({ status }); resolveFetchWith({ status });
const res = await googleScrape(source, target, query); const res = await googleScrape(source, target, query);
expect(res?.errorMsg).toMatch(/retrieving/); expect("errorMsg" in res && res.errorMsg).toMatch(/retrieving/);
}); });
it("returns correct message on network error", async () => { it("returns correct message on network error", async () => {
fetchMock.mockRejectOnce(); fetchMock.mockRejectOnce();
const res = await googleScrape(source, target, query); const res = await googleScrape(source, target, query);
expect(res?.errorMsg).toMatch(/retrieving/); expect("errorMsg" in res && res.errorMsg).toMatch(/retrieving/);
}); });
it("returns correct message on parsing wrong class", async () => { it("returns correct message on parsing wrong class", async () => {
@@ -41,7 +41,7 @@ describe("googleScrape", () => {
resolveFetchWith(html); resolveFetchWith(html);
const res = await googleScrape(source, target, query); const res = await googleScrape(source, target, query);
expect(res?.errorMsg).toMatch(/parsing/); expect("errorMsg" in res && res.errorMsg).toMatch(/parsing/);
}); });
}); });

View File

@@ -33,11 +33,16 @@ export function replaceBoth(
return { source, target }; return { source, target };
} }
export function retrieveFiltered() { export function retrieveFromType(type?: LangType): [string, string][] {
const [sourceLangs, targetLangs] = langTypes.map(type => ( const langEntries = Object.entries(languages);
Object.entries(languages).filter(([code]) => (
if (!type)
return langEntries;
return langEntries.filter(([code]) => (
!Object.keys(exceptions[type]).includes(code) !Object.keys(exceptions[type]).includes(code)
))
)); ));
return { sourceLangs, targetLangs }; }
export function getName(code: string): string | null {
return isKeyOf(languages)(code) ? languages[code] : null;
} }

View File

@@ -7,8 +7,9 @@ export async function googleScrape(
target: string, target: string,
query: string query: string
): Promise<{ ): Promise<{
translationRes?: string, translationRes: string
errorMsg?: string } | {
errorMsg: string
}> { }> {
const parsed = replaceBoth("mapping", { source, target }); const parsed = replaceBoth("mapping", { source, target });
const res = await fetch( const res = await fetch(
@@ -56,10 +57,7 @@ export function extractSlug(slug: string[]): {
} }
} }
export async function textToSpeechScrape(lang: string, text?: string) { export async function textToSpeechScrape(lang: string, text: string) {
if (!text)
return null;
const { target: parsedLang } = replaceBoth("mapping", { source: "", target: lang }); const { target: parsedLang } = replaceBoth("mapping", { source: "", target: lang });
const lastSpace = text.lastIndexOf(" ", 200); const lastSpace = text.lastIndexOf(" ", 200);