Skip to content

Commit

Permalink
Feat: Request signature generation to interact with API gateway (#2226)
Browse files Browse the repository at this point in the history
* introduced proxy to backend with redirect to auth

* feat: introduced signature generation for interecting with api gateway

* fix: added documentation and resolved some changes requested

* adds documentation

* fix: added comment to explain the purpose of [...path] file

* Fix linting issues

* fix: added support for content and auth service through API gateway

* fix: resolve build issue for static props building

* fix: fixed env var name

---------

Co-authored-by: Osama Sayed <[email protected]>
  • Loading branch information
mohsinayoob and osamasayed authored Oct 31, 2024
1 parent 755e807 commit 82487a1
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 57 deletions.
5 changes: 4 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,7 @@ NEXT_PUBLIC_NOVU_APP_ID=
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SERVER_SENTRY_ENABLED=false
NEXT_PUBLIC_CLIENT_SENTRY_ENABLED=true
NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed

NODE_TLS_REJECT_UNAUTHORIZED=0 #set this only when SSL is self signed
SIGNATURE_TOKEN=1234
INTERNAL_CLIENT_ID=QDC_WEB
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,13 @@
"@sanity/client": "^5.2.1",
"@sanity/image-url": "^1.0.2",
"@sentry/nextjs": "^7.77.0",
"@types/crypto-js": "^4.2.2",
"@umalqura/core": "^0.0.7",
"@xstate/react": "^3.0.1",
"classnames": "^2.3.2",
"clipboard-copy": "^4.0.1",
"cookie": "^0.5.0",
"crypto-js": "^4.2.0",
"firebase": "^9.10.0",
"fuse.js": "^6.6.2",
"groq": "^3.4.0",
Expand Down
78 changes: 68 additions & 10 deletions src/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import { camelizeKeys } from 'humps';
import { NextApiRequest } from 'next';

import { SearchRequestParams, SearchMode } from '@/types/Search/SearchRequestParams';
import NewSearchResponse from '@/types/Search/SearchResponse';
Expand Down Expand Up @@ -28,6 +29,7 @@ import {
makeByRangeVersesUrl,
makeWordByWordTranslationsUrl,
} from '@/utils/apiPaths';
import generateSignature from '@/utils/auth/signature';
import { SearchRequest, AdvancedCopyRequest, PagesLookUpRequest } from 'types/ApiRequests';
import {
TranslationsResponse,
Expand Down Expand Up @@ -59,27 +61,65 @@ export const SEARCH_FETCH_OPTIONS = {

export const OFFLINE_ERROR = 'OFFLINE';

export const X_AUTH_SIGNATURE = 'x-auth-signature';
export const X_TIMESTAMP = 'x-timestamp';
export const X_INTERNAL_CLIENT = 'x-internal-client';

export const fetcher = async function fetcher<T>(
input: RequestInfo,
init?: RequestInit,
init: RequestInit = {},
isStaticBuild: boolean = false,
): Promise<T> {
// if the user is not online when making the API call
if (typeof window !== 'undefined' && !window.navigator.onLine) {
throw new Error(OFFLINE_ERROR);
}
const res = await fetch(input, init);

let reqInit = init;
if (isStaticBuild) {
const req: NextApiRequest = {
url: typeof input === 'string' ? input : input.url,
method: init.method || 'GET',
body: init.body,
headers: init.headers,
query: {},
} as NextApiRequest;

const { signature, timestamp } = generateSignature(req, req.url);
const headers = {
...init.headers,
[X_AUTH_SIGNATURE]: signature,
[X_TIMESTAMP]: timestamp,
[X_INTERNAL_CLIENT]: process.env.INTERNAL_CLIENT_ID,
};
reqInit = { ...init, headers };
}

const res = await fetch(input, reqInit);
if (!res.ok || res.status === 500 || res.status === 404) {
throw res;
}
const json = await res.json();
return camelizeKeys(json);
};

/**
* Get the verses of a specific chapter.
*
* @param {string | number} id the ID of the chapter.
* @param {string} locale the locale.
* @param {Record<string, unknown>} params optional parameters.
* @param {boolean} isStaticBuild flag indicating if the request is for a static build.
*
* @returns {Promise<VersesResponse>}
*/
export const getChapterVerses = async (
id: string | number,
locale: string,
params?: Record<string, unknown>,
): Promise<VersesResponse> => fetcher<VersesResponse>(makeVersesUrl(id, locale, params));
isStaticBuild: boolean = false,
): Promise<VersesResponse> =>
fetcher<VersesResponse>(makeVersesUrl(id, locale, params, isStaticBuild), {}, isStaticBuild);

export const getRangeVerses = async (
locale: string,
Expand All @@ -90,11 +130,15 @@ export const getRangeVerses = async (
* Get the current available translations with the name translated in the current language.
*
* @param {string} language we use this to get translated names of authors in specific the current language.
* @param {boolean} isStaticBuild flag indicating if the request is for a static build.
*
* @returns {Promise<TranslationsResponse>}
*/
export const getAvailableTranslations = async (language: string): Promise<TranslationsResponse> =>
fetcher(makeTranslationsUrl(language));
export const getAvailableTranslations = async (
language: string,
isStaticBuild: boolean = false,
): Promise<TranslationsResponse> =>
fetcher(makeTranslationsUrl(language, isStaticBuild), {}, isStaticBuild);

/**
* Get the current available wbw translations with the name translated in the current language.
Expand All @@ -111,23 +155,31 @@ export const getAvailableWordByWordTranslations = async (
* Get the current available languages with the name translated in the current language.
*
* @param {string} language we use this to get language names in specific the current language.
* @param {boolean} isStaticBuild flag indicating if the request is for a static build.
*
* @returns {Promise<LanguagesResponse>}
*/
export const getAvailableLanguages = async (language: string): Promise<LanguagesResponse> =>
fetcher(makeLanguagesUrl(language));
export const getAvailableLanguages = async (
language: string,
isStaticBuild: boolean = false,
): Promise<LanguagesResponse> =>
fetcher(makeLanguagesUrl(language, isStaticBuild), {}, isStaticBuild);

/**
* Get list of available reciters.
*
* @param {string} locale the locale.
* @param {string[]} fields optional fields to include.
* @param {boolean} isStaticBuild flag indicating if the request is for a static build.
*
* @returns {Promise<RecitersResponse>}
*/
export const getAvailableReciters = async (
locale: string,
fields?: string[],
): Promise<RecitersResponse> => fetcher(makeAvailableRecitersUrl(locale, fields));
isStaticBuild: boolean = false,
): Promise<RecitersResponse> =>
fetcher(makeAvailableRecitersUrl(locale, fields, isStaticBuild), {}, isStaticBuild);

export const getReciterData = async (reciterId: string, locale: string): Promise<ReciterResponse> =>
fetcher(makeReciterUrl(reciterId, locale));
Expand All @@ -139,15 +191,21 @@ export const getReciterData = async (reciterId: string, locale: string): Promise
*
* @param {number} reciterId
* @param {number} chapter the id of the chapter
* @param {boolean} segments flag to include segments.
* @param {boolean} isStaticBuild flag indicating if the request is for a static build.
*
* @returns {Promise<AudioData>}
*/

export const getChapterAudioData = async (
reciterId: number,
chapter: number,
segments = false,
isStaticBuild: boolean = false,
): Promise<AudioData> => {
const res = await fetcher<AudioDataResponse>(
makeChapterAudioDataUrl(reciterId, chapter, segments),
makeChapterAudioDataUrl(reciterId, chapter, segments, isStaticBuild),
{},
isStaticBuild,
);

if (res.error) {
Expand Down
13 changes: 12 additions & 1 deletion src/pages/api/proxy/[...path].ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import { EventEmitter } from 'events';
import { createProxyMiddleware, fixRequestBody } from 'http-proxy-middleware';
import { NextApiRequest, NextApiResponse } from 'next';

import { X_AUTH_SIGNATURE, X_INTERNAL_CLIENT, X_TIMESTAMP } from '@/api';
import generateSignature from '@/utils/auth/signature';

// Define error messages in a constant object
const ERROR_MESSAGES = {
PROXY_ERROR: 'Proxy error',
Expand All @@ -18,7 +21,7 @@ EventEmitter.defaultMaxListeners = Number(process.env.PROXY_DEFAULT_MAX_LISTENER
// to the backend server, allowing for features like cookie handling and request body fixing, which are essential
// for maintaining session state and ensuring correct request formatting while in a cross domain env.
const apiProxy = createProxyMiddleware<NextApiRequest, NextApiResponse>({
target: process.env.NEXT_PUBLIC_AUTH_BASE_URL,
target: process.env.API_GATEWAY_URL,
changeOrigin: true,
pathRewrite: { '^/api/proxy': '' }, // eslint-disable-line @typescript-eslint/naming-convention
secure: process.env.NEXT_PUBLIC_VERCEL_ENV === 'production', // Disable SSL verification to avoid UNABLE_TO_VERIFY_LEAF_SIGNATURE error for dev
Expand All @@ -31,6 +34,14 @@ const apiProxy = createProxyMiddleware<NextApiRequest, NextApiResponse>({
proxyReq.setHeader('Cookie', req.headers.cookie);
}

// Generate and attach signature headers
const requestUrl = `${process.env.API_GATEWAY_URL}${req.url}`;
const { signature, timestamp } = generateSignature(req, requestUrl);

proxyReq.setHeader(X_AUTH_SIGNATURE, signature);
proxyReq.setHeader(X_TIMESTAMP, timestamp);
proxyReq.setHeader(X_INTERNAL_CLIENT, process.env.INTERNAL_CLIENT_ID);

// Fix the request body if bodyParser is involved
fixRequestBody(proxyReq, req);
},
Expand Down
35 changes: 25 additions & 10 deletions src/pages/media/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ const MediaMaker: NextPage<MediaMaker> = ({
isValidating: isVersesValidating,
error: versesError,
} = useSWRImmutable<VersesResponse>(
makeVersesUrl(surah, lang, API_PARAMS),
() => getChapterVerses(surah, lang, API_PARAMS),
makeVersesUrl(surah, lang, API_PARAMS, true),
() => getChapterVerses(surah, lang, API_PARAMS, true),
{
fallbackData: defaultVerses,
revalidateOnMount: shouldRefetchVersesData,
Expand All @@ -191,8 +191,8 @@ const MediaMaker: NextPage<MediaMaker> = ({
isValidating: isAudioValidating,
error: audioError,
} = useSWRImmutable<AudioData>(
makeChapterAudioDataUrl(reciter, surah, true),
() => getChapterAudioData(reciter, surah, true),
makeChapterAudioDataUrl(reciter, surah, true, true),
() => getChapterAudioData(reciter, surah, true, true),
{
fallbackData: defaultAudio,
// only revalidate when the reciter or chapter has changed
Expand Down Expand Up @@ -444,14 +444,29 @@ const MediaMaker: NextPage<MediaMaker> = ({
);
};

const fetchRecitersAndTranslations = async (locale) => {
const { reciters } = await getAvailableReciters(locale, [], true);
const { translations } = await getAvailableTranslations(locale, true);
return { reciters, translations };
};

const fetchChapterData = async (locale) => {
const chaptersData = await getAllChaptersData(locale);
const englishChaptersList = await getAllChaptersData('en');
return { chaptersData, englishChaptersList };
};

const fetchVersesAndAudio = async (locale) => {
const verses = await getChapterVerses(DEFAULT_SURAH, locale, DEFAULT_API_PARAMS, true);
const chapterAudioData = await getChapterAudioData(DEFAULT_RECITER_ID, DEFAULT_SURAH, true, true);
return { verses, chapterAudioData };
};

export const getStaticProps: GetStaticProps = async ({ locale }) => {
try {
const { reciters } = await getAvailableReciters(locale, []);
const { translations } = await getAvailableTranslations(locale);
const chaptersData = await getAllChaptersData(locale);
const englishChaptersList = await getAllChaptersData('en');
const verses = await getChapterVerses(DEFAULT_SURAH, locale, DEFAULT_API_PARAMS);
const chapterAudioData = await getChapterAudioData(DEFAULT_RECITER_ID, DEFAULT_SURAH, true);
const { reciters, translations } = await fetchRecitersAndTranslations(locale);
const { chaptersData, englishChaptersList } = await fetchChapterData(locale);
const { verses, chapterAudioData } = await fetchVersesAndAudio(locale);

return {
props: {
Expand Down
55 changes: 32 additions & 23 deletions src/pages/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,30 +384,39 @@ const Search: NextPage<SearchProps> = ({ translations }): JSX.Element => {
};

export const getStaticProps: GetStaticProps = async ({ locale }) => {
const [availableLanguagesResponse, availableTranslationsResponse] = await Promise.all([
getAvailableLanguages(locale),
getAvailableTranslations(locale),
]);

let translations = [];
let languages = [];
if (availableLanguagesResponse.status !== 500) {
const { languages: responseLanguages } = availableLanguagesResponse;
languages = responseLanguages;
}
if (availableTranslationsResponse.status !== 500) {
const { translations: responseTranslations } = availableTranslationsResponse;
translations = responseTranslations;
try {
const [availableLanguagesResponse, availableTranslationsResponse] = await Promise.all([
getAvailableLanguages(locale, true),
getAvailableTranslations(locale, true),
]);

let translations = [];
let languages = [];
if (availableLanguagesResponse.status !== 500) {
const { languages: responseLanguages } = availableLanguagesResponse;
languages = responseLanguages;
}
if (availableTranslationsResponse.status !== 500) {
const { translations: responseTranslations } = availableTranslationsResponse;
translations = responseTranslations;
}
const chaptersData = await getAllChaptersData(locale);

return {
props: {
chaptersData,
languages,
translations,
},
};
} catch (e) {
console.log(e);
return {
props: {
hasError: true,
},
};
}
const chaptersData = await getAllChaptersData(locale);

return {
props: {
chaptersData,
languages,
translations,
},
};
};

export default Search;
17 changes: 14 additions & 3 deletions src/utils/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { decamelizeKeys } from 'humps';

import stringify from './qs-stringify';
import { getBasePath } from './url';

import { Mushaf, MushafLines, QuranFont, QuranFontMushaf } from 'types/QuranReader';

Expand All @@ -15,24 +16,34 @@ const API_ROOT_PATH = '/api/qdc';
export const API_HOST =
process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' ? PRODUCTION_API_HOST : STAGING_API_HOST;

const { API_GATEWAY_URL } = process.env;

/**
* Generates a url to make an api call to our backend
*
* @param {string} path the path for the call
* @param {Record<string, unknown>} parameters optional query params, {a: 1, b: 2} is parsed to "?a=1&b=2"
* @param {boolean} [isStaticBuild=false] - Flag indicating if the URL is for a static build.
* @returns {string}
*/
export const makeUrl = (path: string, parameters?: Record<string, unknown>): string => {
export const makeUrl = (
path: string,
parameters?: Record<string, unknown>,
isStaticBuild: boolean = false,
): string => {
const BASE_PATH = getBasePath();
const API_PROXY = `${BASE_PATH}/api/proxy/content`;
const API_URL = isStaticBuild ? `${API_GATEWAY_URL}/content` : API_PROXY;
if (!parameters) {
return `${API_HOST}${API_ROOT_PATH}${path}`;
return `${API_URL}${API_ROOT_PATH}${path}`;
}

const decamelizedParams = decamelizeKeys(parameters);

// The following section parses the query params for convenience
// E.g. parses {a: 1, b: 2} to "?a=1&b=2"
const queryParameters = `?${stringify(decamelizedParams)}`;
return `${API_HOST}${API_ROOT_PATH}${path}${queryParameters}`;
return `${API_URL}${API_ROOT_PATH}${path}${queryParameters}`;
};

/**
Expand Down
Loading

0 comments on commit 82487a1

Please sign in to comment.