http-клиент
На основе axios.create
Можно создать свой httpClient на основе axios, в котором можно разместить все общие параметры и подключить перехватчики.
import axios from 'axios';
import { API_URL } from 'apiUrl';
const httpClient = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// https://stackoverflow.com/questions/43051291/attach-authorization-header-for-all-axios-requests
httpClient.interceptors.request.use(
config => {
// Do something before request is sent
// console.log('interceptors request config', config);
// обращаемся к user-token (он может быть в ls, cookies, store)
const token = 'inject your user token'; // testStore.getState().app.userData.token;
// будет подставляться user-token ко всем запросам в headers, если он есть
config.headers.Authorization = token || '';
return config;
},
error => {
console.log('interceptors request error', error);
dispatch(
setAlertMessageThunk({ message: `${error}`, type: 'error' }) as never,
);
// Do something with request error
return Promise.reject(error);
},
);
// Некоторые полезные методы для перехватчиков
// Автоподстановка токена
const getAuthorizationHeaderSetterInterceptor = (
getToken: () => string
) => (config: AxiosRequestConfig) => {
const accessToken = getToken();
if (accessToken && !isAuthRequest(config.url)) {
config.headers['Authorization'] = `Bearer ${accessToken}`;
}
return config;
};
// Обработка неуспешной авторизации
const getTokenInvalidInterceptor = (error: AxiosError) => {
if (error.response?.status === 401 || error.response?.data.message === 'Invalid JWT Token') {
store.dispatch(logoutAction());
if (window.location.pathname.length < 1) {
window.location.href = '/';
}
}
return Promise.reject(error);
}
// Проверка соединения
const getConnectionErrorInterceptor = (error: AxiosError) => {
const connectionError = fetch('https://www.yandex.ru/', {
mode: 'no-cors',
}).catch(() => {
store.dispatch(setMessageAction(messageHandler('Отсутствует соединение с интернетом!')))
})
return Promise.reject(error);
}
Применение
loadOrders: async (query: string): Promise<{ success: boolean, data?: IAdministrationOrder[] }> => {
try {
const { status, data: { orders } } = await httpClient.get(`${url}/orders${query}`);
return {
success: status === 200,
data: orders || []
}
} catch (e) {
return {
success: false
}
}
},
reExport: async (routeIds: number[]): Promise<boolean> => {
try {
const { status } = await httpClient.post(`${url}/routes/export`, {
routeIds
});
return status === 200;
} catch (e) {
return false;
}
},
removeOrder: async (orderId: number, routeId: number): Promise<boolean> => {
try {
const { status } = await httpClient.delete(`${url}/routes/orders?routeId=${routeId}`, {
data: { orderIds: [orderId] }
});
return status === 200;
} catch (e) {
return false;
}
},
На основе класса и axios
export class HttpClient {
private authStore: AuthStore;
private axios: AxiosInstance;
private tokenExpiryListeners: { (): void }[] = [];
constructor() {
this.tokenExpiryListeners = [];
this.axios = axios.create({
baseURL: APP_URL,
responseType: 'json',
headers: {
'content-type': 'application/json',
},
});
this.axios.interceptors.request.use(config => {
if (this.authStore.token) {
config.headers.Authorization = `Bearer ${this.authStore.token}`;
}
config.withCredentials = true;
return config;
});
this.axios.interceptors.response.use(
response => {
return response;
},
error => {
// Обработка Unauthorized error
if (error.response && error.response.status === 401) {
this.tokenExpiryListeners.forEach(function (l) {
l();
});
}
return Promise.reject(error.response);
},
);
this.onTokenExpiry(() => this.authStore.logout());
}
setAppStore(authStore: AuthStore) {
this.authStore = authStore;
}
get(url: string, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.get(url, config)
.then((response: any) => {
return new HttpResponse(response.data, undefined, response.headers);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
delete(url: string, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.delete(url, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
head<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R> {
return this.axios.head(url, config);
}
options<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R> {
return this.axios.options(url, config);
}
graphQuery<T = any>(query: GraphqlQuery): Promise<GraphQLResponse<T>> {
return this.axios
.post('/graphql', query.build())
.then(response => {
let queryData: T | undefined;
if (response.data?.data) {
const keys = Object.keys(response.data?.data);
if (keys.length > 0) {
queryData = response.data?.data[keys[0]];
}
}
let errors;
if (response.data?.errors) {
errors = response.data.errors;
}
let extensions;
if (response.data?.extensions) {
extensions = response.data.extensions;
}
return new GraphQLResponse<T>(queryData, errors, extensions);
})
.catch(error => {
// http errors
// eslint-disable-next-line no-console
console.error('Ошибка при выполнении GraphQL запроса', error);
return new GraphQLResponse<T>(undefined, error);
});
}
post(url: string, data?: any, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.post(url, data, config)
.then((response: any) => {
return new HttpResponse(response.data, undefined, response.headers);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
put(url: string, data?: any, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.put(url, data, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
patch(url: string, data?: any, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.patch(url, data, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
onTokenExpiry(c: any) {
this.tokenExpiryListeners.push(c);
}
}
const httpClient = new HttpClient();
export default httpClient;
Применение
generateReport: (parameters: GenerateReportParams): Promise<HttpResponse> => {
return httpClient.post('/api/v1/service/reports/generate', parameters);
},
fetchReport: (request: FetchReportParams): Promise<HttpResponse> => {
return httpClient.post('/api/v1/reporting/fetchReport', request.reportParameters, {
params: { reportName: request.reportName },
});
},
buildReport: (request: BuildReportParams): Promise<HttpResponse> => {
return httpClient.post('/api/v1/reporting/buildReport', request.reportData, {
params: { reportName: request.reportName },
});
},
API_URL & env
yarn add cross-env
.env.development
# NEXT_PUBLIC_URL=some-url.development.com
NEXT_PUBLIC_URL=https://stage1.producthired.com/api/api
.env.production
# NEXT_PUBLIC_URL=some-url.production.com
NEXT_PUBLIC_URL=https://proe.producthired.com/api/api
Запуск с конкретной переменной:
"scripts": {
"dev": "cross-env NODE_ENV=development env-cmd -f ./api/.env.development next dev",
"dev:8888": "cross-env NODE_ENV=development env-cmd -f ./api/.env.development next dev -p 8888",
"build": "cross-env NODE_ENV=production env-cmd -f ./api/.env.development next build",
}
apiUrl
export enum EnvList {
DEVELOPMENT = 'development',
PRODUCTION = 'production',
}
const { DEVELOPMENT, PRODUCTION } = EnvList;
export const API_URL = process.env.NEXT_PUBLIC_URL;
// можно сохранять в отдельную переменную
// const ENV = process.env;
export const returnEnv = (): EnvList => {
switch (process.env.NODE_ENV) {
case 'development':
return DEVELOPMENT;
case 'production':
return PRODUCTION;
default:
return DEVELOPMENT;
}
};
export const isDev = returnEnv() === DEVELOPMENT;
export const isProd = returnEnv() === PRODUCTION;
Naming
В create-react-app
или react-scripts
все кастомные env-переменные дожны иметь префикс REACT_APP_
иначе они будут игнорироваться.
HTTP-клиент на классе
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { SERVER_HOST } from '@configuration/config';
export default class HttpResponse {
constructor(public data?: any, public error?: string) {}
}
export class HttpClient {
private axios: AxiosInstance;
constructor() {
this.axios = axios.create({
baseURL: SERVER_HOST,
responseType: 'json',
headers: {
'content-type': 'application/json',
},
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
get<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.get(url, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
post<T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.post(url, data, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
put<T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.put(url, data, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
delete<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.delete(url, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
head<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R> {
return this.axios.head(url, config);
}
options<T = any, R = AxiosResponse<T>>(url: string, config?: AxiosRequestConfig): Promise<R> {
return this.axios.options(url, config);
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
patch<T = any, R = AxiosResponse<T>>(url: string, data?: any, config?: AxiosRequestConfig): Promise<HttpResponse> {
return this.axios
.patch(url, data, config)
.then((response: any) => {
return new HttpResponse(response.data);
})
.catch((error: any) => {
return new HttpResponse(undefined, error);
});
}
}
const httpClient = new HttpClient();
export default httpClient;
Пример HTTP Client
const METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
DELETE: 'DELETE',
};
// Самая простая версия. Реализовать штучку со всеми проверками им предстоит в конце спринта
// Необязательный метод
function queryStringify(data) {
if (typeof data !== 'object') {
throw new Error('Data must be object');
}
// Здесь достаточно и [object Object] для объекта
const keys = Object.keys(data);
return keys.reduce((result, key, index) => {
return `${result}${key}=${data[key]}${index < keys.length - 1 ? '&' : ''}`;
}, '?');
}
class HTTPTransport {
get = (url, options = {}) => {
return this.request(url, {...options, method: METHODS.GET}, options.timeout);
};
post = (url, options = {}) => {
return this.request(url, {...options, method: METHODS.POST}, options.timeout);
};
put = (url, options = {}) => {
return this.request(url, {...options, method: METHODS.PUT}, options.timeout);
};
delete = (url, options = {}) => {
return this.request(url, {...options, method: METHODS.DELETE}, options.timeout);
};
request = (url, options = {}, timeout = 5000) => {
const {headers = {}, method, data} = options;
return new Promise(function(resolve, reject) {
if (!method) {
reject('No method');
return;
}
const xhr = new XMLHttpRequest();
const isGet = method === METHODS.GET;
xhr.open(
method,
isGet && !!data
? `${url}${queryStringify(data)}`
: url,
);
Object.keys(headers).forEach(key => {
xhr.setRequestHeader(key, headers[key]);
});
xhr.onload = function() {
resolve(xhr);
};
xhr.onabort = reject;
xhr.onerror = reject;
xhr.timeout = timeout;
xhr.ontimeout = reject;
if (isGet || !data) {
xhr.send();
} else {
xhr.send(data);
}
});
};
}
Счетчик попыток HTTP запросов
function fetchWithRetry(url, options = {}) {
const {tries = 1} = options;
function onError(err){
const triesLeft = tries - 1;
if (!triesLeft){
throw err;
}
return fetchWithRetry(url, {...options, tries: triesLeft});
}
return fetch(url, options).catch(onError);
}
Классический fetch
позволяет делать HTTP-запросы к серверу. Однако часто бывает необходимо сделать повторный аналогичный запрос автоматически, если что-то пошло не так. Например, интернет «тупит» или сервер не смог с первого раза корректно обработать запрос.
fetch с «ретраями
» реализуется через классический метод fetch
. Его реализация была показана в одном из уроков ранее, здесь же расскажем про особенности реализации «повторных попыток».
Основными тонкостями здесь являются рекурсия и сигнатура промисов.
Функция принимает:
url
— конечный пользовательский адрес сервера;options
— объект с параметрами запроса (метод запроса, заголовки и другие нужные вам параметры). Кроме них, будем передавать дополнительный — количество оставшихся попыток на запрос.
Система с перезапросами работает следующим образом. Нужно сделать запрос на сервер https://api.com/user
. Сделаем его через fetch
:
function fetchWithRetry(url, options = {}) {
return fetch(url, options);
}
fetchWithRetry('https://api.com/user', {method: 'GET'});
Запрос выполнился неуспешно. В данном случае «не успехом» будем считать только исключения при выполнении. То есть 400-й или 500-й HTTP-запрос «не успехом» не считается, поскольку любой ответ сервера — это уже успех с точки зрения клиент-серверного взаимодействия. Допустим, задали всего три попытки на запрос. В таком случае, есть возможность ещё два раза перезапросить ответ.
Давайте реализуем такое поведение. Для этого нужно написать обработчик на catch
, который перезапустит этот же запрос, но при этом уменьшит количество оставшихся попыток:
function onError(err){
// Возьмём из замыкания
const triesLeft = tries - 1;
if (!triesLeft){
throw err;
}
return fetchWithRetry(url, {...options, tries: triesLeft});
}
Функция onError
уменьшает количество оставшихся попыток и делает заново точно такой же HTTP-запрос:
function fetchWithRetry(url, options = {}) {
const {tries = 1} = options;
function onError(err){
const triesLeft = tries - 1;
if (!triesLeft){
throw err;
}
return fetchWithRetry(url, {...options, tries: triesLeft});
}
return fetch(url, options).catch(onError);
}
Недостающие элементы пазла добавлены. Во-первых, в замыкание onError
было добавлено количество попыток, то есть tries
. Во-вторых, теперь на упавший запрос будет вызван обработчик ошибки.
Таким образом, функция делает HTTP-запросы. В случае успеха (то есть вернулся успешный ответ от сервера в допустимое количество попыток), возвращается ровно тот же ответ, что и у обычного fetсh
. Если же произошла ошибка (как уже ранее выяснили, исключения) возвращается точно также ответ, аналогичный ответу fetch
.
То есть мы реализовали функцию, которая имеет сигнатуру fetch
, написанного в одном из уроков, и обладает точно таким же поведением с одним НО: можно указать количество попыток. При количестве попыток равному единице, данная функция будет вести себя точно так же, как и обычный fetch
.