* Initial RESTful API

* RESTful API tests

* Scrapping error handling refactored

* Initial GraphQL API

* GraphQL API tests
This commit is contained in:
David
2021-03-28 23:17:47 +02:00
committed by GitHub
parent 7288e9ace7
commit 2938f780aa
15 changed files with 1873 additions and 671 deletions

View File

@@ -3,22 +3,13 @@ import { Stack, HStack, Heading, Text, Icon, useColorModeValue } from "@chakra-u
import { FaSadTear } from "react-icons/fa";
import Layout from "./Layout";
const statusTexts: {
[key: string]: string
} = {
400: "Bad Request",
404: "This page could not be found",
405: "Method Not Allowed",
500: "Internal Server Error",
fallback: "An unexpected error has occurred"
};
type Props = {
statusCode: number
statusCode: number,
statusText: string
};
const CustomError: FC<Props> = ({ statusCode }) => (
<Layout customTitle={`${statusCode} - ${statusTexts?.[statusCode] ?? statusTexts.fallback}`}>
const CustomError: FC<Props> = ({ statusCode, statusText }) => (
<Layout customTitle={`${statusCode} - ${statusText}`}>
<Stack
color={useColorModeValue("lingva.900", "lingva.100")}
direction={["column", null, "row"]}
@@ -34,7 +25,7 @@ const CustomError: FC<Props> = ({ statusCode }) => (
<Icon as={FaSadTear} boxSize={10} />
</HStack>
<Text as="h2" fontSize="xl">
{statusTexts?.[statusCode] ?? statusTexts.fallback}
{statusText}
</Text>
</Stack>
</Layout>

View File

@@ -1,4 +1,5 @@
{
"baseUrl": "http://localhost:3000",
"defaultCommandTimeout": 10000,
"projectId": "qgjdyd"
}

View File

@@ -16,10 +16,13 @@
"@chakra-ui/react": "^1.3.4",
"@emotion/react": "^11.1.5",
"@emotion/styled": "^11.1.5",
"apollo-server-micro": "^2.22.1",
"cheerio": "^1.0.0-rc.5",
"framer-motion": "^3.10.3",
"graphql": "^15.5.0",
"next": "10.0.8",
"next-pwa": "^5.0.6",
"nextjs-cors": "^1.0.4",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-icons": "^4.2.0",
@@ -37,6 +40,7 @@
"@types/user-agents": "^1.0.0",
"@typescript-eslint/eslint-plugin": "^4.0.0",
"@typescript-eslint/parser": "^4.0.0",
"apollo-server-testing": "^2.22.1",
"babel-eslint": "^10.0.0",
"cypress": "^6.6.0",
"eslint": "^7.5.0",
@@ -52,6 +56,7 @@
"faker": "^5.4.0",
"jest": "^26.6.3",
"jest-fetch-mock": "^3.0.3",
"node-mocks-http": "^1.10.1",
"typescript": "^4.2.3",
"wait-on": "^5.3.0"
},

View File

@@ -2,7 +2,7 @@ import { FC } from "react";
import { CustomError } from "../components";
const My404: FC = () => (
<CustomError statusCode={404} />
<CustomError statusCode={404} statusText={"This page could not be found"} />
);
export default My404;

View File

@@ -2,7 +2,7 @@ import { FC } from "react";
import { CustomError } from "../components";
const My500: FC = () => (
<CustomError statusCode={500} />
<CustomError statusCode={500} statusText={"Internal Server Error"} />
);
export default My500;

View File

@@ -3,13 +3,13 @@ import { GetStaticPaths, GetStaticProps, InferGetStaticPropsType } from "next";
import Router from "next/router";
import { Stack, VStack, HStack, IconButton } from "@chakra-ui/react";
import { FaExchangeAlt } from "react-icons/fa";
import { CustomError, Layout, LangSelect, TranslationArea } from "../components";
import { Layout, LangSelect, TranslationArea } from "../components";
import { useToastOnLoad } from "../hooks";
import { googleScrape, extractSlug, textToSpeechScrape } from "../utils/translate";
import { retrieveFiltered, replaceBoth } from "../utils/language";
import langReducer, { Actions, initialState } from "../utils/reducer";
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, statusCode, errorMsg, initial }) => {
const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, translationRes, audio, errorMsg, initial }) => {
const [{ source, target, query, delayedQuery, translation }, dispatch] = useReducer(langReducer, initialState);
const handleChange = (e: ChangeEvent<HTMLTextAreaElement | HTMLSelectElement>) => {
@@ -55,9 +55,7 @@ const Page: FC<InferGetStaticPropsType<typeof getStaticProps>> = ({ home, transl
updateDeps: initial
});
return statusCode ? (
<CustomError statusCode={statusCode} />
) : (
return (
<Layout home={home}>
<VStack px={[8, null, 24, 40]} w="full">
<HStack px={[1, null, 3, 4]} w="full">
@@ -160,7 +158,7 @@ export const getStaticProps: GetStaticProps = async ({ params }) => {
source, target, query
}
},
revalidate: !textScrape.errorMsg && !textScrape.statusCode
revalidate: !textScrape.errorMsg
? 2 * 30 * 24 * 60 * 60 // 2 months
: 1
};

80
pages/api/graphql.ts Normal file
View File

@@ -0,0 +1,80 @@
import { ApolloServer, gql, IResolvers } from "apollo-server-micro";
import { NextApiHandler } from "next";
import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "../../utils/translate";
export const typeDefs = gql`
type Query {
translation(source: String="auto" target: String="en" query: String!): Translation!
audio(lang: String! query: String!): Entry!
}
type Translation {
source: Entry!
target: Entry!
}
type Entry {
lang: String!
text: String
audio: [Int]
}
`;
export const resolvers: IResolvers = {
Query: {
translation(_, args) {
const { source, target, query } = args;
return {
source: {
lang: source,
text: query
},
target: {
lang: target
}
};
},
audio(_, args) {
return {
lang: args.lang,
text: args.query
};
}
},
Translation: {
async target(parent) {
const { source, target } = parent;
const { translationRes } = await googleScrape(source.lang, target.lang, source.text);
return {
lang: target.lang,
text: translationRes
};
}
},
Entry: {
async audio(parent) {
const { lang, text } = parent;
return await textToSpeechScrape(lang, text);
}
}
};
export const config = {
api: {
bodyParser: false
}
};
const apolloHandler = new ApolloServer({ typeDefs, resolvers }).createHandler({ path: "/api/graphql" });
const handler: NextApiHandler = async (req, res) => {
await NextCors(req, res, {
methods: ["GET", "POST"],
origin: "*"
});
return req.method !== "OPTIONS"
? apolloHandler(req, res)
: res.end();
};
export default handler;

View File

@@ -0,0 +1,49 @@
import { NextApiHandler } from "next";
import NextCors from "nextjs-cors";
import { googleScrape, textToSpeechScrape } from "../../../utils/translate";
type Data = {
translation?: string,
audio?: number[],
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 !== 3)
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 [source, target, query] = slug;
if (source === "audio") {
const audio = await textToSpeechScrape(target, query);
return audio
? res.status(200).json({ audio })
: res.status(500).json({ error: "An error occurred while retrieving the audio" });
}
const { translationRes, errorMsg } = await googleScrape(source, target, query);
if (errorMsg)
return res.status(500).json({ error: errorMsg });
res.status(200).json({ translation: translationRes });
}
export default handler;

View File

@@ -2,8 +2,11 @@ import { render, screen } from "../reactUtils";
import faker from "faker";
import CustomError from "../../components/CustomError";
const code = faker.random.number({ min: 400, max: 599 });
const text = faker.random.words();
it("loads the layout correctly", async () => {
render(<CustomError statusCode={404} />);
render(<CustomError statusCode={code} statusText={text} />);
expect(screen.getByRole("link", { name: /skip to content/i })).toBeEnabled();
expect(await screen.findByRole("img", { name: /logo/i })).toBeVisible();
@@ -12,13 +15,10 @@ it("loads the layout correctly", async () => {
expect(screen.getByText(/\xA9/)).toBeVisible();
});
it("renders a not found message on 404 code", () => {
render(<CustomError statusCode={404} />);
expect(screen.getByText(/this page could not be found/i)).toBeVisible();
});
it("renders the correct status code", () => {
it("renders the correct status code & text", () => {
const code = faker.random.number({ min: 400, max: 599 });
render(<CustomError statusCode={code} />);
render(<CustomError statusCode={code} statusText={text} />);
expect(screen.getByText(code)).toBeVisible();
expect(screen.getByText(text)).toBeVisible();
});

View File

@@ -173,12 +173,6 @@ describe("Page", () => {
expect(btnCopy).toBeEnabled();
});
it("renders error page on status code", async () => {
const code = faker.random.number({ min: 400, max: 599 });
render(<Page statusCode={code} />);
await waitFor(() => expect(screen.getByText(code)).toBeVisible());
});
it("shows alert correctly on error", async () => {
const errorMsg = faker.random.words();
render(<Page errorMsg={errorMsg} />);

View File

@@ -0,0 +1,179 @@
import { createTestClient } from "apollo-server-testing";
import { ApolloServer } from "apollo-server-micro";
import faker from "faker";
import { htmlRes, resolveFetchWith } from "../../commonUtils";
import { typeDefs, resolvers } from "../../../pages/api/graphql";
beforeEach(() => {
fetchMock.resetMocks();
});
const { query } = createTestClient(new ApolloServer({ typeDefs, resolvers }));
it("doesn't trigger fetch if neither target nor audio are specified", async () => {
const text = faker.random.words();
const { data } = await query({
query: `
query($text: String!) {
translation(query: $text) {
source {
text
}
}
}
`,
variables: { text }
});
expect(data).toMatchObject({ translation: { source: { text } } });
expect(fetch).not.toHaveBeenCalled();
});
it("returns translation triggering fetch", async () => {
const text = faker.random.words();
const translation = faker.random.words();
resolveFetchWith(htmlRes(translation));
const { data } = await query({
query: `
query($text: String!) {
translation(query: $text) {
target {
text
}
}
}
`,
variables: { text }
});
expect(data).toMatchObject({ translation: { target: { text: translation } } });
expect(fetch).toHaveBeenCalledTimes(1);
});
it("returns audio triggering fetch", async () => {
const lang = faker.random.locale();
const text = faker.random.words();
resolveFetchWith({ status: 200 });
const { data } = await query({
query: `
query($lang: String! $text: String!) {
audio(lang: $lang query: $text) {
lang
text
audio
}
}
`,
variables: { lang, text }
});
expect(data).toMatchObject({ audio: { lang, text, audio: expect.any(Array) } });
expect(fetch).toHaveBeenCalledTimes(1);
});
it("returns null on translation error", async () => {
const text = faker.random.words();
fetchMock.mockRejectOnce();
const { data } = await query({
query: `
query($text: String!) {
translation(query: $text) {
target {
text
}
}
}
`,
variables: { text }
});
expect(data).toMatchObject({ translation: { target: { text: null } } });
});
it("returns null on audio error", async () => {
const lang = faker.random.locale();
const text = faker.random.words();
fetchMock.mockRejectOnce();
const { data } = await query({
query: `
query($lang: String! $text: String!) {
audio(lang: $lang query: $text) {
audio
}
}
`,
variables: { lang, text }
});
expect(data).toMatchObject({ audio: { audio: null } });
});
it("keeps a default value for both source and target languages", async () => {
const text = faker.random.words();
fetchMock.mockRejectOnce();
const { data } = await query({
query: `
query($text: String!) {
translation(query: $text) {
source {
lang
}
target {
lang
}
}
}
`,
variables: { text }
});
expect(data).toMatchObject({ translation: { source: { lang: "auto" }, target: { lang: "en" } } });
});
it("throws error on empty query in translation", async () => {
const { errors } = await query({
query: `
query {
translation {
source {
lang
}
target {
lang
}
}
}
`
});
expect(errors).toBeTruthy();
});
it("throws error on empty lang or query in audio", async () => {
const lang = faker.random.locale();
const text = faker.random.words();
const { errors: queryErrors } = await query({
query: `
query($lang: String!) {
audio(lang: $lang) {
lang
text
}
}
`,
variables: { lang }
});
expect(queryErrors).toBeTruthy();
const { errors: langErrors } = await query({
query: `
query($text: String!) {
audio(query: $text) {
lang
text
}
}
`,
variables: { text }
});
expect(langErrors).toBeTruthy();
});

View File

@@ -0,0 +1,95 @@
import httpMocks from "node-mocks-http";
import faker from "faker";
import { htmlRes, resolveFetchWith } from "../../../commonUtils";
import handler from "../../../../pages/api/v1/[[...slug]]";
beforeEach(() => {
fetchMock.resetMocks();
});
const source = faker.random.locale();
const target = faker.random.locale();
const query = faker.random.words();
const slug = [source, target, query];
it("returns 404 on <3 params", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: [source, target] }
});
await handler(req, res);
expect(res.statusCode).toBe(404);
});
it("returns 404 on >3 params", async () => {
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: [source, target, query, ""] }
});
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: { slug }
});
await handler(req, res);
expect(res.statusCode).toBe(405);
});
it("returns translation on scrapping resolve", async () => {
const translationRes = faker.random.words();
resolveFetchWith(htmlRes(translationRes));
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ translation: translationRes });
});
it("returns 500 on scrapping error", async () => {
fetchMock.mockRejectOnce();
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug }
});
await handler(req, res);
expect(res.statusCode).toBe(500);
expect(res._getJSONData()).toStrictEqual({ error: expect.any(String) });
});
it("returns audio on audio request", async () => {
resolveFetchWith({ status: 200 });
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["audio", target, query] }
});
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(res._getJSONData()).toStrictEqual({ audio: expect.any(Array) });
});
it("returns 500 on audio request error", async () => {
fetchMock.mockRejectOnce();
const { req, res } = httpMocks.createMocks<any, any>({
method: "GET",
query: { slug: ["audio", target, query] }
});
await handler(req, res);
expect(res.statusCode).toBe(500);
});

View File

@@ -19,11 +19,12 @@ describe("googleScrape", () => {
expect(await googleScrape(source, target, query)).toStrictEqual({ translationRes });
});
it("returns status code on request error", async () => {
it("returns correct message on request error", async () => {
const status = faker.random.number({ min: 400, max: 499 });
resolveFetchWith({ status });
expect(await googleScrape(source, target, query)).toStrictEqual({ statusCode: status });
const res = await googleScrape(source, target, query);
expect(res?.errorMsg).toMatch(/retrieving/);
});
it("returns correct message on network error", async () => {

View File

@@ -8,7 +8,6 @@ export async function googleScrape(
query: string
): Promise<{
translationRes?: string,
statusCode?: number,
errorMsg?: string
}> {
const parsed = replaceBoth("mapping", { source, target });
@@ -23,16 +22,11 @@ export async function googleScrape(
() => null
);
if (!res)
if (!res?.ok)
return {
errorMsg: "An error occurred while retrieving the translation"
}
if (!res.ok)
return {
statusCode: res.status
};
const html = await res.text();
const translationRes = cheerio.load(html)(".result-container").text().trim();

2067
yarn.lock

File diff suppressed because it is too large Load Diff