diff --git a/package-lock.json b/package-lock.json index bcf3bc6..7a2edac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "@nextui-org/react": "^2.4.2", + "axios": "^1.7.2", "framer-motion": "^11.2.12", "i18next": "^23.11.5", "i18next-http-backend": "^2.5.2", "i18next-resources-to-backend": "^1.2.1", "next": "14.2.4", + "next-connect": "^1.0.0", "next-i18n-router": "^5.5.0", "react": "^18", "react-dom": "^18", @@ -4173,6 +4175,12 @@ "tslib": "^2.4.0" } }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -4956,6 +4964,12 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.19", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", @@ -5017,6 +5031,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.1.1.tgz", @@ -5357,6 +5382,18 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -5660,6 +5697,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -6537,6 +6583,26 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -6561,6 +6627,20 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -8046,6 +8126,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", @@ -8183,6 +8284,19 @@ } } }, + "node_modules/next-connect": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-connect/-/next-connect-1.0.0.tgz", + "integrity": "sha512-FeLURm9MdvzY1SDUGE74tk66mukSqL6MAzxajW7Gqh6DZKBZLrXmXnGWtHJZXkfvoi+V/DUe9Hhtfkl4+nTlYA==", + "license": "MIT", + "dependencies": { + "@tsconfig/node16": "^1.0.3", + "regexparam": "^2.0.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/next-i18n-router": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/next-i18n-router/-/next-i18n-router-5.5.0.tgz", @@ -9588,6 +9702,12 @@ "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9817,6 +9937,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regexparam": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-2.0.2.tgz", + "integrity": "sha512-A1PeDEYMrkLrfyOwv2jwihXbo9qxdGD3atBYQA9JJgreAx8/7rC6IUkWOw2NQlOxLp2wL0ifQbh1HuidDfYA6w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", diff --git a/package.json b/package.json index a125955..1cd9f8b 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ }, "dependencies": { "@nextui-org/react": "^2.4.2", + "axios": "^1.7.2", "framer-motion": "^11.2.12", "i18next": "^23.11.5", "i18next-http-backend": "^2.5.2", "i18next-resources-to-backend": "^1.2.1", "next": "14.2.4", + "next-connect": "^1.0.0", "next-i18n-router": "^5.5.0", "react": "^18", "react-dom": "^18", diff --git a/src/app/api/route.ts b/src/app/api/route.ts new file mode 100644 index 0000000..e186197 --- /dev/null +++ b/src/app/api/route.ts @@ -0,0 +1,42 @@ +import { serverSideRequest } from '@/src/services/api-services/server-side'; +import { AxiosError } from 'axios'; +import { NextApiRequest, NextApiResponse } from 'next'; +import { createRouter } from 'next-connect'; + +const router = createRouter(); + +const routerHandler = router.handler({ + onError: (err, _: NextApiRequest, res: NextApiResponse) => { + console.error(err); + res.status(500).send({ statusCode: 500, message: 'Internal Server Error' }); + }, + onNoMatch: (_: NextApiRequest, res: NextApiResponse) => { + res.status(405).send({ statusCode: 405, message: 'Method not allowed' }); + }, +}); + +router.post(async (req: NextApiRequest, res: NextApiResponse) => { + try { + const response = await serverSideRequest(req, req.body); + res.setHeader('Set-Cookie', response.headers['set-cookie'] ? response.headers['set-cookie'] : []); + res.status(200).send(response.data); + } catch (err) { + if (!(err instanceof AxiosError)) throw err; + const msg = { + request: { + url: err.config?.url, + }, + status: err.response?.status, + statusText: err.response?.statusText, + body: JSON.stringify(err.response?.data), + }; + + console.error(msg); + + const statusCode = err.response?.status ?? 500; + + res.status(statusCode).send(err.response?.data ?? { statusCode, message: 'Internal Server Error' }); + } +}); + +export default routerHandler; diff --git a/src/enums/error-codes.enum.ts b/src/enums/error-codes.enum.ts new file mode 100644 index 0000000..e5a79a1 --- /dev/null +++ b/src/enums/error-codes.enum.ts @@ -0,0 +1,7 @@ +export enum ErrorCode { + API_ERROR = 'API_ERROR', + NOT_FOUND = 'NOT_FOUND', + NOT_ALLOWED = 'NOT_ALLOWED', + WRONG_CREDENTIALS = 'WRONG_CREDENTIALS', + ALREADY_EXISTS = 'ALREADY_EXISTS', +} diff --git a/src/hooks/useFetch.ts b/src/hooks/useFetch.ts new file mode 100644 index 0000000..afdd835 --- /dev/null +++ b/src/hooks/useFetch.ts @@ -0,0 +1,50 @@ +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { ErrorCode } from '../enums/error-codes.enum'; +import { clientSideRequest } from '../services/api-services/client-side'; +import { APIContext, APIOperation } from '../services/api-services/common'; + +type useFetchType = { + isLoading: boolean; + setIsLoading: Dispatch>; + fetchDataWithLoadingTimeout: ( + config: Omit & { + op: T; + }, + ) => Promise< + | { + success: true; + data: R; + } + | { success: false; errorCode: ErrorCode } + >; +}; + +const useFetch = (loadingTimeout = 100): useFetchType => { + const [isLoading, setIsLoading] = useState(false); + let timeout: ReturnType; + + useEffect(() => { + return () => clearInterval(timeout); + }); + + const fetchDataWithLoadingTimeout = async ( + config: Omit & { op: T }, + ): Promise<{ success: true; data: R } | { success: false; errorCode: ErrorCode }> => { + timeout = setTimeout(() => { + setIsLoading(true); + }, loadingTimeout); + + const res = await clientSideRequest(config); + + clearInterval(timeout); + setIsLoading(false); + + // eslint-disable-next-line + // @ts-ignore + return res; + }; + + return { isLoading, setIsLoading, fetchDataWithLoadingTimeout }; +}; + +export default useFetch; diff --git a/src/services/api-services/client-side.ts b/src/services/api-services/client-side.ts new file mode 100644 index 0000000..8e451e0 --- /dev/null +++ b/src/services/api-services/client-side.ts @@ -0,0 +1,35 @@ +import { ErrorCode } from '@/src/enums/error-codes.enum'; +import axios, { AxiosError } from 'axios'; +import { APIContext, APIOperation } from './common'; + +const clientSideRequest = async ( + options: Omit & { op: T }, +): Promise<{ success: true; data: R } | { success: false; errorCode: ErrorCode }> => { + if (typeof window === 'undefined') throw new Error('Request can only be performed on the client side'); + + try { + const { data } = await axios.post( + '/api', + { + ...options, + }, + { withCredentials: true }, + ); + + return { success: true, data }; + } catch (err) { + if (!(err instanceof AxiosError)) return { success: false, errorCode: ErrorCode.API_ERROR }; + + if (typeof err.response?.data !== 'object') return { success: false, errorCode: ErrorCode.API_ERROR }; + + if (typeof err.response.data.error !== 'string') return { success: false, errorCode: ErrorCode.API_ERROR }; + + const errorCode = err.response.data.error; + + if (!Object.values(ErrorCode).includes(errorCode)) return { success: false, errorCode: ErrorCode.API_ERROR }; + + return { success: false, errorCode }; + } +}; + +export { clientSideRequest }; diff --git a/src/services/api-services/common.ts b/src/services/api-services/common.ts new file mode 100644 index 0000000..bfc6fd2 --- /dev/null +++ b/src/services/api-services/common.ts @@ -0,0 +1,55 @@ +import { ToBeRemoved } from '@/src/types/response.types'; + +enum APIOperation { + TO_BE_REMOVED = 'get:to-be-removed', +} + +type APIContext = { + [APIOperation.TO_BE_REMOVED]: RequestContext< + APIOperation.TO_BE_REMOVED, + ToBeRemoved, + { email: string; name: string; password: string; publicKey: string; privateKey: string; iv: string } + >; +}; + +type WithPayload = TPayload extends void + ? TBase + : TBase & { + payload: TPayload; + }; + +type WithURLParams = TURLParams extends void + ? TBase + : TBase & { + params: TURLParams; + }; + +type WithQueryParams = TQuery extends void + ? TBase + : TBase & { + query: TQuery; + }; + +type RequestContext< + TEndpoint extends APIOperation, + TResponse = void, + TPayload = void, + TURLParams = void, + TQuery = void, +> = WithQueryParams< + WithURLParams< + WithPayload< + { + op: TEndpoint; + responseType: TResponse; + headers?: Record; + }, + TPayload + >, + TURLParams + >, + TQuery +>; + +export { APIOperation }; +export type { APIContext }; diff --git a/src/services/api-services/server-side.ts b/src/services/api-services/server-side.ts new file mode 100644 index 0000000..7453cb9 --- /dev/null +++ b/src/services/api-services/server-side.ts @@ -0,0 +1,123 @@ +import axios, { AxiosResponse } from 'axios'; +import { IncomingMessage } from 'http'; +import AppConfiguration from '../../config/app.config'; +import { APIContext, APIOperation } from './common'; + +const stringifyQueryValues = (endpoint: string, query: Record): string => { + const urlSearchParams = new URLSearchParams(); + if (Object.entries(query ?? length === 0)) return endpoint; + + for (const [k, v] of Object.entries(query ?? {})) { + if (Array.isArray(v)) { + for (const t of v) urlSearchParams.append(k, t); + } else if (v !== null) { + urlSearchParams.append(k, v); + } + } + + return `${endpoint}?${urlSearchParams.toString()}`; +}; + +const replaceURLParams = (endpoint: string, params: Record | undefined): string => { + if (!params) return endpoint; + let res = endpoint; + for (const k of Object.keys(params)) { + res = res.replace(`{${k}}`, params[k].toString()); + } + return res; +}; + +const req = async ( + endpointPrefix: string, + request: IncomingMessage, + options: Omit, +): Promise> => { + if (!Object.values(APIOperation).includes(options.op)) throw new Error(`Invalid operation: ${options.op}`); + + const endpoint = + endpointPrefix + + stringifyQueryValues( + replaceURLParams( + options.op.replace(/^[^:]+:/, ''), + (options as unknown as { params: Record }).params, + ), + { + ...(options as unknown as { query: Record }).query, + }, + ); + const httpMethod = options.op.split(':', 1)[0] as 'get' | 'post' | 'put' | 'patch' | 'delete'; + + const headers = options.headers; + + const cookies = request.headers.cookie ?? ''; + + switch (httpMethod) { + case 'get': { + return axios.get(endpoint, { + headers: { + ...headers, + cookie: cookies, + }, + withCredentials: true, + }); + } + case 'post': { + return axios.post(endpoint, (options as unknown as { payload: unknown }).payload, { + headers: { + ...headers, + cookie: cookies, + }, + withCredentials: true, + }); + } + case 'put': { + return axios.put(endpoint, (options as unknown as { payload: unknown }).payload, { + headers: { + ...headers, + cookie: cookies, + }, + withCredentials: true, + }); + } + case 'patch': { + return axios.patch(endpoint, (options as unknown as { payload: unknown }).payload, { + headers: { + ...headers, + cookie: cookies, + }, + withCredentials: true, + }); + } + case 'delete': { + return axios.delete(endpoint, { + headers: { + ...headers, + cookie: cookies, + }, + withCredentials: true, + }); + } + default: { + throw new Error(`Cannot perform operation: ${httpMethod}`); + } + } +}; + +const serverSideRequest = async ( + request: IncomingMessage, + options: Omit, +): Promise> => { + const res = await rawServerSideRequest(request, options); + return res as AxiosResponse; +}; + +const rawServerSideRequest = async ( + request: IncomingMessage, + options: Omit, +): Promise> => { + if (typeof window !== 'undefined') throw new Error('Request can only be performed Serverside'); + + return req(AppConfiguration.get('REMOTE_URL'), request, options); +}; + +export { serverSideRequest }; diff --git a/src/types/response.types.ts b/src/types/response.types.ts new file mode 100644 index 0000000..9725e03 --- /dev/null +++ b/src/types/response.types.ts @@ -0,0 +1,3 @@ +export type ToBeRemoved = { + test: string; +};