// fetch.ts // Copyright (C) 2020 Dwayne Harris // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // You should have received a copy of the GNU General Public License // along with this program. If not, see . import { UnauthorizedError, BadRequestError, NotFoundError, ServerError, } from './errors' import { pathJoin } from '../utils' import { LOCAL_STORAGE_ACCESS_TOKEN_KEY, LOCAL_STORAGE_ACCESS_TOKEN_EXPIRES_AT_KEY, LOCAL_STORAGE_REFRESH_TOKEN_KEY, } from '../constants' import { FetchOptions, FormNotification, NotificationType } from '../types' interface RefreshResponse { id: string access: string refresh: string expires: number } interface FormError { field?: string message: string } interface ErrorResponse { message: string errors?: FormError[] } type APIFetch = (options: FetchOptions) => Promise const mapErrorsToFormNotifications = (errors?: FormError[]): FormNotification[] => { if (!errors) return [] return errors.map(e => ({ field: e.field, type: NotificationType.Error, message: e.message, })) } const checkResponse = async (response: Response, retry?: () => Promise) => { switch (response.status) { case 400: { const { message, errors } = await response.json() as ErrorResponse throw new BadRequestError(message, mapErrorsToFormNotifications(errors)) } case 401: { if (retry) return await retry() throw new UnauthorizedError() } case 404: throw new NotFoundError() default: { throw new ServerError() } } } const getResponseData = async (response: Response) => { try { return await response.json() } catch (err) { return {} } } export const apiFetch: APIFetch = async (options: FetchOptions) => { const { path, method = 'get', body } = options const contentType = 'application/json' const doFetch = async () => { const headers = new Headers({ ...options.headers, 'Accept': contentType, }) if (body) headers.append('Content-Type', contentType) const accessToken = localStorage.getItem(LOCAL_STORAGE_ACCESS_TOKEN_KEY) if (accessToken) headers.append('Authorization', `Bearer ${accessToken}`) return await fetch(`/${pathJoin('api', path)}`, { headers, method, body: body ? JSON.stringify(body) : undefined, }) } const doRefresh = async () => { const accessToken = localStorage.getItem(LOCAL_STORAGE_ACCESS_TOKEN_KEY) const refreshToken = localStorage.getItem(LOCAL_STORAGE_REFRESH_TOKEN_KEY) if (accessToken && refreshToken) { const refreshResponse = await fetch('/api/v1/refresh', { headers: new Headers({ 'Content-Type': contentType, 'Authorization': `Bearer ${accessToken}` }), method: 'post', body: JSON.stringify({ refresh: refreshToken, }), }) if (refreshResponse.status !== 201) { throw new UnauthorizedError() } const data = await getResponseData(refreshResponse) as RefreshResponse localStorage.setItem(LOCAL_STORAGE_ACCESS_TOKEN_KEY, data.access) localStorage.setItem(LOCAL_STORAGE_ACCESS_TOKEN_EXPIRES_AT_KEY, data.expires.toString()) localStorage.setItem(LOCAL_STORAGE_REFRESH_TOKEN_KEY, data.refresh) const secondResponse = await doFetch() if (secondResponse.ok) { return await getResponseData(secondResponse) } await checkResponse(secondResponse) } } const accessTokenExpiresAt = localStorage.getItem(LOCAL_STORAGE_ACCESS_TOKEN_EXPIRES_AT_KEY) if (accessTokenExpiresAt && Date.now() >= parseInt(accessTokenExpiresAt, 10)) { return await doRefresh() } const response = await doFetch() if (response.ok) { return await getResponseData(response) } return await checkResponse(response, doRefresh) }