diff --git a/src/lib/http/client.ts b/src/lib/http/client.ts index bf3d3a7..6bbfa93 100644 --- a/src/lib/http/client.ts +++ b/src/lib/http/client.ts @@ -1,170 +1,15 @@ import type { - AxiosError, - AxiosRequestConfig, - AxiosResponse, - ResponseType, -} from "axios"; -import axios from "axios"; - -export type HttpMethod = "GET" | "PUT" | "PATCH" | "POST" | "DELETE"; - -export interface RequestConfig { - url: string; - method: HttpMethod; - params?: unknown; - data?: TData; - responseType?: ResponseType; - signal?: AbortSignal; - headers?: AxiosRequestConfig["headers"]; - baseURL?: string; -} - -export interface ResponseConfig { - data: TData; - status: number; - statusText: string; - headers?: AxiosResponse["headers"]; -} - -export type ResponseErrorConfig = AxiosError; + BaseFetch, + RequestConfig, + ResponseErrorConfig, +} from "@/shared/fetch/BaseFetch"; +import { AxiosFetchStrategy } from "@/shared/fetch/AxiosFetch"; const defaultBaseURL = (import.meta as any).env?.VITE_API_BASE_URL || "/backend/api/web"; -export const axiosInstance = axios.create({ - baseURL: defaultBaseURL, - headers: { - "Content-Type": "application/json", - }, - paramsSerializer: { - serialize(params) { - const search = new URLSearchParams(); - if (params && typeof params === "object") { - for (const [key, value] of Object.entries(params)) { - if (value == null) continue; - if (Array.isArray(value)) { - for (const v of value) { - if (v != null) search.append(key, String(v)); - } - } else { - search.append(key, String(value)); - } - } - } - return search.toString(); - }, - }, -}); - -// TODO: AUTHORIZATION LOGIC -// axiosInstance.interceptors.request.use((config) => { -// const accessToken = getAccessToken(); - -// if (accessToken) { -// config.headers.Authorization = `Bearer ${accessToken}`; -// } - -// return config; -// }); - -// let isRefreshing = false; -// let refreshSubscribers: ((token: string) => void)[] = []; - -// const subscribeTokenRefresh = (cb: (token: string) => void) => { -// refreshSubscribers.push(cb); -// }; - -// const onRefreshed = (token: string) => { -// refreshSubscribers.forEach((cb) => cb(token)); -// refreshSubscribers = []; -// }; - -// axiosInstance.interceptors.response.use( -// (response) => response, -// async (error: AxiosError) => { -// const originalRequest = error.config as AxiosRequestConfig & { -// _retry?: boolean; -// }; - -// if (error.response?.status === 401 && !originalRequest._retry) { -// originalRequest._retry = true; - -// if (!isRefreshing) { -// isRefreshing = true; -// try { -// const accessToken = await refreshAccessToken(); -// axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`; - -// const result = await axiosInstance(originalRequest); -// onRefreshed(accessToken); - -// isRefreshing = false; -// return result; -// } catch (refreshError) { -// isRefreshing = false; -// router.navigate({ to: "/signin" }); -// return Promise.reject(refreshError); -// } -// } - -// return new Promise((resolve) => { -// subscribeTokenRefresh(async (token: string) => { -// if (originalRequest.headers) { -// originalRequest.headers.Authorization = `Bearer ${token}`; -// } - -// resolve(axiosInstance(originalRequest)); -// }); -// }); -// } - -// return Promise.reject(error); -// }, -// ); - -function normalizeParams(params: unknown): unknown { - if ( - params && - typeof params === "object" && - "params" in (params as any) && - Object.keys(params as any).length === 1 - ) { - return (params as any).params; - } - return params; -} +export const client: BaseFetch = new AxiosFetchStrategy(defaultBaseURL); -export default async function client< - TData, - TError = unknown, - TVariables = unknown, ->( - config: RequestConfig, -): Promise> { - try { - const response = await axiosInstance.request< - TData, - AxiosResponse, - TVariables - >({ - method: config.method, - url: config.url, - params: normalizeParams(config.params) as any, - data: (config as unknown as RequestConfig).data as any, - responseType: config.responseType, - signal: config.signal, - headers: config.headers, - baseURL: config.baseURL ?? axiosInstance.defaults.baseURL, - }); +export default client.request.bind(client); - return { - data: response.data, - status: response.status, - statusText: response.statusText, - headers: response.headers, - }; - } catch (error) { - // eslint-disable-next-line - throw error as ResponseErrorConfig; - } -} +export type { RequestConfig, ResponseErrorConfig }; diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..071ac6e --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,4 @@ +import type { BaseStorage } from "@/shared/storage/BaseStorage"; +import { LocalStorage } from "@/shared/storage/LocalStorage"; + +export const storage: BaseStorage = new LocalStorage(); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 2ea4eb6..a596b33 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,6 +2,8 @@ import type { ClassValue } from "clsx"; import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; +export type AlwaysNullable = Exclude | null; + export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/src/main.tsx b/src/main.tsx index e29cf00..2f70961 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,9 +2,10 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter, RouterProvider } from "@tanstack/react-router"; import ReactDOM from "react-dom/client"; +import { AuthProvider } from "@/shared/auth/AuthContext"; + // Import the generated route tree import { routeTree } from "./routeTree.gen"; - import "./styles.css"; // Create a new router instance @@ -38,8 +39,10 @@ const rootElement = document.getElementById("app"); if (rootElement && !rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement); root.render( - - - , + + + + + , ); } diff --git a/src/shared/auth/AuthContext.tsx b/src/shared/auth/AuthContext.tsx new file mode 100644 index 0000000..fb1280d --- /dev/null +++ b/src/shared/auth/AuthContext.tsx @@ -0,0 +1,122 @@ +import type { Dispatch, FC, JSX, SetStateAction } from "react"; +import { createContext, use, useState } from "react"; +import { storage } from "@/lib/storage"; + +interface IAuthContext { + isAuth: boolean; + setIsAuth: Dispatch>; +} + +const AuthContext = createContext({ + isAuth: false, + setIsAuth: (_) => {}, +}); + +interface AuthProviderProps { + children: JSX.Element | JSX.Element[]; +} + +enum TOKENS_KEYS { + ACCESS = "ACCESS_TOKEN", + REFRESH = "REFRESH_TOKEN", +} + +const setAccessToken = (token: string) => + storage.set(TOKENS_KEYS.ACCESS, token); +const setRefreshToken = (token: string) => + storage.set(TOKENS_KEYS.REFRESH, token); + +const deleteAccessToken = () => storage.delete(TOKENS_KEYS.ACCESS); +const deleteRefreshToken = () => storage.delete(TOKENS_KEYS.REFRESH); + +export const getAccessToken = () => storage.get(TOKENS_KEYS.ACCESS); +export const getRefreshToken = () => storage.get(TOKENS_KEYS.REFRESH); + +export const AuthProvider: FC = ({ children }) => { + const [isAuth, setIsAuth] = useState(false); + + return ( + + {children} + + ); +}; + +export const refreshAccessToken = async () => { + try { + const access_token = ""; + + return access_token as string; + } catch (e) { + deleteAccessToken(); + deleteRefreshToken(); + console.log(e); + throw e; + } +}; + +export const useAuth = () => { + const ctx = use(AuthContext); + + if (!ctx) { + throw new Error("useAuth must be used within "); + } + + const { isAuth, setIsAuth } = ctx; + + const me = async () => { + try { + const token = getAccessToken(); + + if (!token) return false; + + // TODO: add fetch /me + + setIsAuth(true); + + return true; + } catch (e) { + console.error(e); + + setIsAuth(false); + + return false; + } + }; + + const signin = async (data: unknown) => { + try { + // TODO: add create token fetch + + const access_token = ""; + const refresh_token = ""; + + setAccessToken(access_token ?? ""); + setRefreshToken(refresh_token ?? ""); + + setIsAuth(true); + + // TODO: add return response data + return data; + } catch (e) { + deleteAccessToken(); + deleteRefreshToken(); + + throw e; + } + }; + + const signup = async () => {}; + + return { + isAuth, + me, + signin, + signup, + }; +}; diff --git a/src/shared/fetch/AxiosFetch.ts b/src/shared/fetch/AxiosFetch.ts new file mode 100644 index 0000000..bbb1aa8 --- /dev/null +++ b/src/shared/fetch/AxiosFetch.ts @@ -0,0 +1,348 @@ +import type { + AxiosError, + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, +} from "axios"; +import type { RequestConfig, ResponseConfig } from "@/shared/fetch/BaseFetch"; +import axios, { isAxiosError } from "axios"; +import { getAccessToken, refreshAccessToken } from "@/shared/auth/AuthContext"; +import { BaseFetch } from "@/shared/fetch/BaseFetch"; +import { NetworkError } from "@/shared/fetch/errors/NetworkError"; + +export type ResponseErrorConfig = AxiosError; + +export class AxiosFetchStrategy extends BaseFetch { + private instance: AxiosInstance; + + private isRefreshing = false; + private refreshSubscribers: ((token: string) => void)[] = []; + + constructor(url: string) { + super(url); + + this.instance = this.createAxiosInstance(); + this.interceptors(); + } + + public async get( + url: string, + config?: Partial>, + ): Promise> { + try { + const response = await this.instance.get(url, config); + + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + }; + } catch (e) { + if (isAxiosError(e)) { + const response = e.response; + + throw new NetworkError({ + status: e.status as number, + message: e.message, + request: e.request, + response: response + ? { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + } + : undefined, + }); + } + throw e; + } + } + + public async post( + url: string, + data: TRequestData, + config: RequestConfig, + ): Promise> { + try { + const response = await this.instance.post(url, data, config); + + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + }; + } catch (e) { + if (isAxiosError(e)) { + const response = e.response; + + throw new NetworkError({ + status: e.status as number, + message: e.message, + request: e.request, + response: response + ? { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + } + : undefined, + }); + } + throw e; + } + } + + public async put( + url: string, + data: TRequestData, + config: RequestConfig, + ): Promise> { + try { + const response = await this.instance.put(url, data, config); + + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + }; + } catch (e) { + if (isAxiosError(e)) { + const response = e.response; + + throw new NetworkError({ + status: e.status as number, + message: e.message, + request: e.request, + response: response + ? { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + } + : undefined, + }); + } + throw e; + } + } + + public async patch( + url: string, + data: TRequestData, + config: RequestConfig, + ): Promise> { + try { + const response = await this.instance.patch(url, data, config); + + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + }; + } catch (e) { + if (isAxiosError(e)) { + const response = e.response; + + throw new NetworkError({ + status: e.status as number, + message: e.message, + request: e.request, + response: response + ? { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + } + : undefined, + }); + } + throw e; + } + } + + public async delete( + url: string, + config?: Partial>, + ): Promise> { + try { + const response = await this.instance.delete(url, config); + + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + }; + } catch (e) { + if (isAxiosError(e)) { + const response = e.response; + + throw new NetworkError({ + status: e.status as number, + message: e.message, + request: e.request, + response: response + ? { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + } + : undefined, + }); + } + throw e; + } + } + + public async request( + config: RequestConfig, + ): Promise> { + try { + const response = await this.instance.request< + TData, + AxiosResponse, + TVariables + >({ + method: config.method, + url: config.url, + params: this.normalizeParams(config.params) as any, + data: (config as unknown as RequestConfig).data as any, + responseType: config.responseType, + signal: config.signal, + headers: config.headers, + baseURL: config.baseURL ?? this.baseUrl, + }); + + return { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + }; + } catch (e) { + if (isAxiosError(e)) { + const response = e.response; + + throw new NetworkError({ + status: e.status as number, + message: e.message, + request: e.request, + response: response + ? { + data: response.data, + status: response.status, + statusText: response.statusText, + headers: response.headers as any, + } + : undefined, + }); + } + throw e; + } + } + + private createAxiosInstance() { + return axios.create({ + baseURL: this.baseUrl, + headers: { + "Content-Type": "application/json", + }, + paramsSerializer: { + serialize(params) { + const search = new URLSearchParams(); + if (params && typeof params === "object") { + for (const [key, value] of Object.entries(params)) { + if (value == null) continue; + if (Array.isArray(value)) { + for (const v of value) { + if (v != null) search.append(key, String(v)); + } + } else { + search.append(key, String(value)); + } + } + } + return search.toString(); + }, + }, + }); + } + + private normalizeParams(params: unknown): unknown { + if ( + params && + typeof params === "object" && + "params" in (params as any) && + Object.keys(params as any).length === 1 + ) { + return (params as any).params; + } + return params; + } + + private subscribeTokenRefresh(cb: (token: string) => void) { + this.refreshSubscribers.push(cb); + } + + private onRefreshed(token: string) { + this.refreshSubscribers.forEach((cb) => cb(token)); + this.refreshSubscribers = []; + } + + private interceptors() { + this.instance.interceptors.request.use((config) => { + const accessToken = getAccessToken(); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; + }); + + this.instance.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as AxiosRequestConfig & { + _retry?: boolean; + }; + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + if (!this.isRefreshing) { + this.isRefreshing = true; + try { + const accessToken = await refreshAccessToken(); + this.instance.defaults.headers.Authorization = `Bearer ${accessToken}`; + const result = await this.instance(originalRequest); + this.onRefreshed(accessToken); + this.isRefreshing = false; + return result; + } catch (refreshError) { + this.isRefreshing = false; + // router.navigate({ to: "/signin" }); + return Promise.reject(refreshError); + } + } + return new Promise((resolve) => { + this.subscribeTokenRefresh(async (token: string) => { + if (originalRequest.headers) { + originalRequest.headers.Authorization = `Bearer ${token}`; + } + resolve(this.instance(originalRequest)); + }); + }); + } + return Promise.reject(error); + }, + ); + } +} diff --git a/src/shared/fetch/BaseFetch.ts b/src/shared/fetch/BaseFetch.ts new file mode 100644 index 0000000..dbd2859 --- /dev/null +++ b/src/shared/fetch/BaseFetch.ts @@ -0,0 +1,80 @@ +type HttpMethod = "GET" | "PUT" | "PATCH" | "POST" | "DELETE"; + +interface HeadersCookies { + // "set-cookie"?: string[]; +} + +interface BaseHeaders { + [x: string]: string | string[] | number | boolean | null | undefined; +} + +export type Headers = BaseHeaders & HeadersCookies; + +type ResponseType = + | "arraybuffer" + | "blob" + | "document" + | "json" + | "text" + | "stream" + | "formdata"; + +export interface RequestConfig { + url: string; + method: HttpMethod; + params?: unknown; + data?: TData; + responseType?: ResponseType; + signal?: AbortSignal; + headers?: Headers; + baseURL?: string; +} + +export interface ResponseConfig { + data: TData; + status: number; + statusText: string; + headers?: Headers; +} + +export type ResponseErrorConfig<_TError = unknown> = any; + +export abstract class BaseFetch { + protected baseUrl = ""; + + constructor(url: string) { + this.baseUrl = url; + } + + public abstract get( + url: string, + config?: Partial>, + ): Promise>; + + public abstract post( + url: string, + data: TRequestData, + config?: Partial>, + ): Promise>; + + public abstract patch( + url: string, + data: TRequestData, + config?: Partial>, + ): Promise>; + + public abstract put( + url: string, + data: TRequestData, + config?: Partial>, + ): Promise>; + + public abstract delete( + url: string, + config?: Partial>, + ): Promise>; + + public abstract request( + config: RequestConfig, + ): Promise>; +} diff --git a/src/shared/fetch/errors/NetworkError.ts b/src/shared/fetch/errors/NetworkError.ts new file mode 100644 index 0000000..465e3ac --- /dev/null +++ b/src/shared/fetch/errors/NetworkError.ts @@ -0,0 +1,51 @@ +import type { RequestConfig, ResponseConfig } from "@/shared/fetch/BaseFetch"; + +type NetworkErrorStatus = number; +type NetworkErrorMessage = string; +type NetworkErrorRequest = RequestConfig; +type NetworkErrorResponse = + | (ResponseConfig & { + data?: unknown; + }) + | undefined; + +interface ConstructorPayload { + status: NetworkErrorStatus; + message: NetworkErrorMessage; + request: NetworkErrorRequest; + response: NetworkErrorResponse; +} + +export class NetworkError { + private status: NetworkErrorStatus; + private message: NetworkErrorMessage; + private request: NetworkErrorRequest; + private response: NetworkErrorResponse; + + constructor({ status, message, response, request }: ConstructorPayload) { + this.status = status; + this.message = message; + this.request = request; + this.response = response; + } + + public getStatus() { + return this.status; + } + + public getMessage() { + return this.message; + } + + public getRequest() { + return this.request; + } + + public getResponse() { + return this.response; + } + + public static isMe(error: any): error is NetworkError { + return error instanceof NetworkError; + } +} diff --git a/src/shared/storage/BaseStorage.ts b/src/shared/storage/BaseStorage.ts new file mode 100644 index 0000000..55cd2d7 --- /dev/null +++ b/src/shared/storage/BaseStorage.ts @@ -0,0 +1,16 @@ +import type { AlwaysNullable } from "@/lib/utils"; + +type BaseStorageKey = string; +type BaseStoragePayload = unknown; +type BaseStorageReturnData = AlwaysNullable; +type BaseStorageSwitch = boolean; + +export abstract class BaseStorage { + public abstract get(key: BaseStorageKey): BaseStorageReturnData; + public abstract has(key: BaseStorageKey): BaseStorageSwitch; + public abstract delete(key: BaseStorageKey): BaseStorageSwitch; + public abstract set( + key: BaseStorageKey, + payload: BaseStoragePayload, + ): BaseStorageSwitch; +} diff --git a/src/shared/storage/LocalStorage.ts b/src/shared/storage/LocalStorage.ts new file mode 100644 index 0000000..602698e --- /dev/null +++ b/src/shared/storage/LocalStorage.ts @@ -0,0 +1,33 @@ +import type { AlwaysNullable } from "@/lib/utils"; +import { BaseStorage } from "@/shared/storage/BaseStorage"; + +export class LocalStorage extends BaseStorage { + public get(key: string): AlwaysNullable { + const data = localStorage.getItem(key); + + if (data === null) return null; + + try { + return JSON.parse(data) as AlwaysNullable; + } catch { + return null; + } + } + + public set(key: string, payload: unknown): boolean { + localStorage.setItem(key, String(payload)); + return true; + } + + public delete(key: string): boolean { + localStorage.removeItem(key); + + return true; + } + + public has(key: string): boolean { + const value = this.get(key); + + return value !== null; + } +}