APIs (#3)
* Initial RESTful API * RESTful API tests * Scrapping error handling refactored * Initial GraphQL API * GraphQL API tests
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"defaultCommandTimeout": 10000,
|
||||
"projectId": "qgjdyd"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
80
pages/api/graphql.ts
Normal 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;
|
||||
49
pages/api/v1/[[...slug]].ts
Normal file
49
pages/api/v1/[[...slug]].ts
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
179
tests/pages/api/graphql.test.ts
Normal file
179
tests/pages/api/graphql.test.ts
Normal 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();
|
||||
});
|
||||
95
tests/pages/api/v1/[[...slug]].test.ts
Normal file
95
tests/pages/api/v1/[[...slug]].test.ts
Normal 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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user