diff --git a/README.md b/README.md index 554555a..1aa08e3 100644 --- a/README.md +++ b/README.md @@ -58,16 +58,33 @@ Nearly all the *Lingva* instances should supply a pair of public developer APIs: + GET `/api/v1/:source/:target/:query` ```typescript { - translation?: string, - errorMsg?: string + translation: string } ``` + GET `/api/v1/audio/:lang/:query` ```typescript { - audio?: number[], - errorMsg?: string + audio: number[] +} +``` + ++ 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 { translation(source: String target: String query: String!) { source: { - lang: String! - text: String - audio: [Int] + lang: { + code: String! + name: String! + } + text: String! + audio: [Int]! } target: { - lang: String! - text: String - audio: [Int] + lang: { + code: String! + name: String! + } + text: String! + audio: [Int]! } } audio(lang: String! query: String!) { - lang: String! - text: String - audio: [Int] + lang: { + code: String! + name: String! + } + text: String! + audio: [Int]! + } + languages(type: SOURCE|TARGET) { + code: String! + name: String! } } ``` diff --git a/pages/[[...slug]].tsx b/pages/[[...slug]].tsx index 210bee6..0fefa05 100644 --- a/pages/[[...slug]].tsx +++ b/pages/[[...slug]].tsx @@ -9,7 +9,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { CustomHead, LangSelect, TranslationArea } from "@components"; import { useToastOnLoad } from "@hooks"; 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"; const AutoTranslateButton = dynamic(() => import("@components/AutoTranslateButton"), { ssr: false }); @@ -66,7 +66,8 @@ const Page: FC> = ({ home, transl 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 }); useToastOnLoad({ @@ -191,7 +192,9 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { const [sourceAudio, targetAudio] = await Promise.all([ textToSpeechScrape(source, query), - textToSpeechScrape(target, textScrape.translationRes) + "translationRes" in textScrape + ? textToSpeechScrape(target, textScrape.translationRes) + : null ]); return { @@ -205,7 +208,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { source, target, query } }, - revalidate: !textScrape.errorMsg + revalidate: !("errorMsg" in textScrape) ? 2 * 30 * 24 * 60 * 60 // 2 months : 1 }; diff --git a/pages/api/graphql.ts b/pages/api/graphql.ts index 2c4c89c..0161608 100644 --- a/pages/api/graphql.ts +++ b/pages/api/graphql.ts @@ -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 NextCors from "nextjs-cors"; import { googleScrape, textToSpeechScrape } from "@utils/translate"; +import { retrieveFromType, getName } from "@utils/language"; export const typeDefs = gql` + enum LangType { + SOURCE, + TARGET + } type Query { translation(source: String="auto" target: String="en" query: String!): Translation! audio(lang: String! query: String!): Entry! + languages(type: LangType): [Language]! } type Translation { source: Entry! target: Entry! } type Entry { - lang: String! - text: String - audio: [Int] + lang: Language! + text: String! + audio: [Int]! + } + type Language { + code: String! + name: String! } `; @@ -25,35 +35,65 @@ export const resolvers: IResolvers = { const { source, target, query } = args; return { source: { - lang: source, + lang: { + code: source + }, text: query }, target: { - lang: target + lang: { + code: target + } } }; }, audio(_, args) { + const { lang, query } = args; return { - lang: args.lang, - text: args.query + lang: { + code: lang + }, + text: query }; + }, + languages(_, args) { + const { type } = args; + const langEntries = retrieveFromType(type?.toLocaleLowerCase()); + return langEntries.map(([code, name]) => ({ code, name })); } }, Translation: { async 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 { lang: target.lang, - text: translationRes + text: textScrape.translationRes }; } }, Entry: { async audio(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; } } }; diff --git a/pages/api/v1/[[...slug]].ts b/pages/api/v1/[[...slug]].ts index 91383a2..a9fc236 100644 --- a/pages/api/v1/[[...slug]].ts +++ b/pages/api/v1/[[...slug]].ts @@ -3,9 +3,11 @@ import NextCors from "nextjs-cors"; import { googleScrape, textToSpeechScrape } from "@utils/translate"; type Data = { - translation?: string, - audio?: number[], - error?: string + translation: string, +} | { + audio: number[] +} | { + error: string }; const methods = ["GET"]; @@ -39,11 +41,11 @@ const handler: NextApiHandler = async (req, res) => { : 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) - return res.status(500).json({ error: errorMsg }); - res.status(200).json({ translation: translationRes }); + if ("errorMsg" in textScrape) + return res.status(500).json({ error: textScrape.errorMsg }); + res.status(200).json({ translation: textScrape.translationRes }); } export default handler; diff --git a/pages/api/v1/languages/[[...slug]].ts b/pages/api/v1/languages/[[...slug]].ts new file mode 100644 index 0000000..ebc7b3d --- /dev/null +++ b/pages/api/v1/languages/[[...slug]].ts @@ -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 = 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; diff --git a/tests/pages/api/graphql.test.ts b/tests/pages/api/graphql.test.ts index 118358f..f0c8859 100644 --- a/tests/pages/api/graphql.test.ts +++ b/tests/pages/api/graphql.test.ts @@ -58,7 +58,9 @@ it("returns audio triggering fetch", async () => { query: ` query($lang: String! $text: String!) { audio(lang: $lang query: $text) { - lang + lang { + code + } text audio } @@ -66,15 +68,15 @@ it("returns audio triggering fetch", async () => { `, 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); }); -it("returns null on translation error", async () => { +it("returns null and throws on translation error", async () => { const text = faker.random.words(); fetchMock.mockRejectOnce(); - const { data } = await query({ + const { data, errors } = await query({ query: ` query($text: String!) { translation(query: $text) { @@ -86,15 +88,16 @@ it("returns null on translation error", async () => { `, 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 text = faker.random.words(); fetchMock.mockRejectOnce(); - const { data } = await query({ + const { data, errors } = await query({ query: ` query($lang: String! $text: String!) { audio(lang: $lang query: $text) { @@ -104,29 +107,52 @@ it("returns null on audio error", async () => { `, 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 () => { const text = faker.random.words(); - fetchMock.mockRejectOnce(); + const translation = faker.random.words(); + resolveFetchWith(htmlRes(translation)); const { data } = await query({ query: ` query($text: String!) { translation(query: $text) { source { - lang + lang { + code + name + } } target { - lang + lang { + code + name + } } } } `, 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 () => { @@ -135,10 +161,14 @@ it("throws error on empty query in translation", async () => { query { translation { source { - lang + lang { + code + } } target { - lang + lang { + code + } } } } @@ -155,7 +185,9 @@ it("throws error on empty lang or query in audio", async () => { query: ` query($lang: String!) { audio(lang: $lang) { - lang + lang { + code + } text } } @@ -168,7 +200,9 @@ it("throws error on empty lang or query in audio", async () => { query: ` query($text: String!) { audio(query: $text) { - lang + lang { + code + } text } } @@ -177,3 +211,44 @@ it("throws error on empty lang or query in audio", async () => { }); 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) }); +}); diff --git a/tests/pages/api/v1/languages/[[...slug]].test.ts b/tests/pages/api/v1/languages/[[...slug]].test.ts new file mode 100644 index 0000000..b37f033 --- /dev/null +++ b/tests/pages/api/v1/languages/[[...slug]].test.ts @@ -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({ + 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({ + method: "POST", + query: {} + }); + + await handler(req, res); + expect(res.statusCode).toBe(405); +}); + +it("returns 400 on wrong param", async () => { + const { req, res } = httpMocks.createMocks({ + 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({ + 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({ + 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({ + method: "GET", + query: { slug: ["target"] } + }); + + await handler(req, res); + expect(res.statusCode).toBe(200); + expect(res._getJSONData()).toStrictEqual({ languages: expect.any(Array) }); +}); diff --git a/tests/utils/language.test.ts b/tests/utils/language.test.ts index 3cb12c0..186c817 100644 --- a/tests/utils/language.test.ts +++ b/tests/utils/language.test.ts @@ -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"; describe("replaceBoth", () => { @@ -34,14 +35,34 @@ describe("replaceBoth", () => { }); }); -describe("retrieveFiltered", () => { - const filteredEntries = (langType: LangType) => ( - Object.entries(languages).filter(([code]) => !Object.keys(exceptions[langType]).includes(code)) +describe("retrieveFromType", () => { + const checkExceptions = (langType: LangType) => ( + retrieveFromType(langType).forEach(([code]) => !Object.keys(exceptions).includes(code)) ); - it("filters by exceptions", () => { - const { sourceLangs, targetLangs } = retrieveFiltered(); - expect(sourceLangs).toStrictEqual(filteredEntries("source")); - expect(targetLangs).toStrictEqual(filteredEntries("target")); + it("returns full list on empty type", () => { + expect(retrieveFromType()).toStrictEqual(Object.entries(languages)); + }); + + 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(); }); }); diff --git a/tests/utils/translate.test.ts b/tests/utils/translate.test.ts index d830690..a54401d 100644 --- a/tests/utils/translate.test.ts +++ b/tests/utils/translate.test.ts @@ -24,14 +24,14 @@ describe("googleScrape", () => { resolveFetchWith({ status }); 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 () => { fetchMock.mockRejectOnce(); 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 () => { @@ -41,7 +41,7 @@ describe("googleScrape", () => { resolveFetchWith(html); const res = await googleScrape(source, target, query); - expect(res?.errorMsg).toMatch(/parsing/); + expect("errorMsg" in res && res.errorMsg).toMatch(/parsing/); }); }); diff --git a/utils/language.ts b/utils/language.ts index 9e39991..01327a7 100644 --- a/utils/language.ts +++ b/utils/language.ts @@ -33,11 +33,16 @@ export function replaceBoth( return { source, target }; } -export function retrieveFiltered() { - const [sourceLangs, targetLangs] = langTypes.map(type => ( - Object.entries(languages).filter(([code]) => ( - !Object.keys(exceptions[type]).includes(code) - )) +export function retrieveFromType(type?: LangType): [string, string][] { + const langEntries = Object.entries(languages); + + if (!type) + return langEntries; + return langEntries.filter(([code]) => ( + !Object.keys(exceptions[type]).includes(code) )); - return { sourceLangs, targetLangs }; +} + +export function getName(code: string): string | null { + return isKeyOf(languages)(code) ? languages[code] : null; } diff --git a/utils/translate.ts b/utils/translate.ts index e368972..4cc73ed 100644 --- a/utils/translate.ts +++ b/utils/translate.ts @@ -7,8 +7,9 @@ export async function googleScrape( target: string, query: string ): Promise<{ - translationRes?: string, - errorMsg?: string + translationRes: string +} | { + errorMsg: string }> { const parsed = replaceBoth("mapping", { source, target }); const res = await fetch( @@ -56,10 +57,7 @@ export function extractSlug(slug: string[]): { } } -export async function textToSpeechScrape(lang: string, text?: string) { - if (!text) - return null; - +export async function textToSpeechScrape(lang: string, text: string) { const { target: parsedLang } = replaceBoth("mapping", { source: "", target: lang }); const lastSpace = text.lastIndexOf(" ", 200);