diff --git a/frontend/package.json b/frontend/package.json index f3ea2bd56..4cd4fbe26 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -79,7 +79,6 @@ "@tiptap/vue-3": "2.26.1", "@vueuse/core": "13.5.0", "@vueuse/router": "13.5.0", - "axios": "1.10.0", "blurhash": "2.0.5", "bulma-css-variables": "0.9.33", "change-case": "5.4.4", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 10d2c1bec..20a2b006a 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -109,9 +109,6 @@ importers: '@vueuse/router': specifier: 13.5.0 version: 13.5.0(vue-router@4.5.1(vue@3.5.17(typescript@5.8.3)))(vue@3.5.17(typescript@5.8.3)) - axios: - specifier: 1.10.0 - version: 1.10.0(debug@4.4.1) blurhash: specifier: 2.0.5 version: 2.0.5 diff --git a/frontend/src/helpers/auth.ts b/frontend/src/helpers/auth.ts index 669c3a772..1b12e366e 100644 --- a/frontend/src/helpers/auth.ts +++ b/frontend/src/helpers/auth.ts @@ -1,5 +1,4 @@ -import {AuthenticatedHTTPFactory} from '@/helpers/fetcher' -import type {AxiosResponse} from 'axios' +import {AuthenticatedHTTPFactory, type HttpResponse} from '@/helpers/fetcher' let savedToken: string | null = null @@ -37,7 +36,7 @@ export const removeToken = () => { /** * Refreshes an auth token while ensuring it is updated everywhere. */ -export async function refreshToken(persist: boolean): Promise { +export async function refreshToken(persist: boolean): Promise { const HTTP = AuthenticatedHTTPFactory() try { const response = await HTTP.post('user/token') diff --git a/frontend/src/helpers/fetcher.ts b/frontend/src/helpers/fetcher.ts index 16dee8efb..42cbf8fb9 100644 --- a/frontend/src/helpers/fetcher.ts +++ b/frontend/src/helpers/fetcher.ts @@ -1,36 +1,239 @@ -import axios from 'axios' import {getToken} from '@/helpers/auth' +export type Method = 'GET' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'POST' | 'PUT' | 'PATCH' + +export interface RequestConfig { + url: string + method?: Method + headers?: Record + params?: Record + data?: unknown + baseURL?: string + responseType?: 'json' | 'blob' | 'text' + onUploadProgress?: (progress: {progress: number}) => void + transformRequest?: (data: unknown) => unknown +} + +export interface HttpResponse { + data: T + headers: Record + status: number +} + +export class HttpError extends Error { + status?: number + data?: unknown + response?: Response +} + +class InterceptorManager { + private handlers: Array<(arg: T) => Promise | T> = [] + + use(handler: (arg: T) => Promise | T) { + this.handlers.push(handler) + } + + async run(arg: T): Promise { + for (const handler of this.handlers) { + arg = await handler(arg) + } + return arg + } +} + +class HttpClient { + private baseURL: string + interceptors = { + request: new InterceptorManager(), + response: new InterceptorManager(), + } + + constructor(baseURL: string) { + this.baseURL = baseURL + } + + private buildUrl(config: RequestConfig) { + let url = config.url + if (!url.startsWith('http')) { + url = (config.baseURL ?? this.baseURL ?? '') + url + } + if (config.params) { + const qs = new URLSearchParams(config.params as Record).toString() + if (qs) { + url += (url.includes('?') ? '&' : '?') + qs + } + } + return url + } + + private parseHeaders(headers: Headers): Record { + const result: Record = {} + headers.forEach((v, k) => { + result[k] = v + }) + return result + } + + private prepareRequestBody(data: unknown, transformRequest?: (data: unknown) => unknown): unknown { + let body = data + if (transformRequest) { + body = transformRequest(body) + } + return body + } + + private async parseResponseData(response: Response, responseType?: string): Promise { + if (responseType === 'blob') { + return await response.blob() + } + + if (responseType === 'text') { + return await response.text() + } + + try { + return await response.json() + } catch { + return await response.text() + } + } + + private async fetchRequest(config: RequestConfig): Promise { + const url = this.buildUrl(config) + const init: RequestInit = {method: config.method, headers: config.headers} + + const body = this.prepareRequestBody(config.data, config.transformRequest) + + if (typeof body !== 'undefined') { + if (body instanceof FormData || body instanceof Blob) { + init.body = body as BodyInit + } else if (typeof body === 'string') { + init.body = body + } else { + init.body = JSON.stringify(body) + if (init.headers && !(init.headers as Record)['Content-Type']) { + (init.headers as Record)['Content-Type'] = 'application/json' + } + } + } + + const response = await fetch(url, init) + return { + status: response.status, + headers: this.parseHeaders(response.headers), + data: await this.parseResponseData(response, config.responseType), + } + } + + private xhrRequest(config: RequestConfig): Promise { + return new Promise((resolve, reject) => { + const url = this.buildUrl(config) + const xhr = new XMLHttpRequest() + xhr.open(config.method ?? 'GET', url) + if (config.headers) { + for (const [k, v] of Object.entries(config.headers)) { + xhr.setRequestHeader(k, v) + } + } + if (config.responseType === 'blob') { + xhr.responseType = 'blob' + } + + xhr.upload.onprogress = ev => { + if (config.onUploadProgress && ev.lengthComputable) { + config.onUploadProgress({progress: ev.loaded / ev.total}) + } + } + + xhr.onload = () => { + const headers: Record = {} + const raw = xhr.getAllResponseHeaders().trim().split(/\r?\n/) + for (const line of raw) { + const parts = line.split(': ') + headers[parts.shift()!.toLowerCase()] = parts.join(': ') + } + let data: unknown = xhr.response + if (config.responseType === 'blob') { + data = xhr.response + } else { + try { + data = JSON.parse(xhr.responseText) + } catch { + data = xhr.responseText + } + } + resolve({status: xhr.status, headers, data}) + } + + xhr.onerror = () => { + const err = new HttpError('Network Error') + reject(err) + } + + const body = this.prepareRequestBody(config.data, config.transformRequest) + if (body instanceof FormData || body instanceof Blob) { + xhr.send(body as BodyInit) + } else if (typeof body === 'string' || typeof body === 'undefined') { + xhr.send(body) + } else { + xhr.send(JSON.stringify(body)) + } + }) + } + + async request(config: RequestConfig): Promise { + config = await this.interceptors.request.run({...config}) + let response: HttpResponse + if (config.onUploadProgress) { + response = await this.xhrRequest(config) + } else { + response = await this.fetchRequest(config) + } + response = await this.interceptors.response.run(response) + if (response.status >= 400) { + const err = new HttpError('Request failed with status ' + response.status) + err.status = response.status + err.data = response.data + throw err + } + return response + } + + get(url: string, config: Partial = {}) { + return this.request({...config, url, method: 'GET'}) + } + + delete(url: string, data?: unknown, config: Partial = {}) { + return this.request({...config, url, method: 'DELETE', data}) + } + + post(url: string, data?: unknown, config: Partial = {}) { + return this.request({...config, url, method: 'POST', data}) + } + + put(url: string, data?: unknown, config: Partial = {}) { + return this.request({...config, url, method: 'PUT', data}) + } +} + export function HTTPFactory() { - const instance = axios.create({baseURL: window.API_URL}) - - instance.interceptors.request.use((config) => { - // by setting the baseURL fresh for every request - // we make sure that it is never outdated in case it is updated - config.baseURL = window.API_URL - - return config - }) - - return instance + return new HttpClient(window.API_URL) } -export function AuthenticatedHTTPFactory() { +export function AuthenticatedHTTPFactory() { const instance = HTTPFactory() - - instance.interceptors.request.use((config) => { - config.headers = { - ...config.headers, - 'Content-Type': 'application/json', - } - - // Set the default auth header if we have a token + instance.interceptors.request.use(config => { const token = getToken() - if (token !== null) { - config.headers['Authorization'] = `Bearer ${token}` + config.headers = { + 'Content-Type': 'application/json', + ...config.headers, + ...(token && { Authorization: `Bearer ${token}` }), } return config }) - return instance } + +export type FetchHttpInstance = HttpClient + +export default HttpClient diff --git a/frontend/src/sentry.ts b/frontend/src/sentry.ts index 32362f80e..652322770 100644 --- a/frontend/src/sentry.ts +++ b/frontend/src/sentry.ts @@ -1,7 +1,7 @@ import 'virtual:vite-plugin-sentry/sentry-config' import type {App} from 'vue' import type {Router} from 'vue-router' -import {AxiosError} from 'axios' +import {HttpError} from '@/helpers/fetcher' export default async function setupSentry(app: App, router: Router) { const Sentry = await import('@sentry/vue') @@ -21,9 +21,10 @@ export default async function setupSentry(app: App, router: Router) { tracesSampleRate: 1.0, beforeSend(event, hint) { - if ((typeof hint.originalException?.code !== 'undefined' && - typeof hint.originalException?.message !== 'undefined') - || hint.originalException instanceof AxiosError) { + if ( + (typeof hint.originalException?.code !== 'undefined' && typeof hint.originalException?.message !== 'undefined') + || hint.originalException instanceof HttpError + ) { return null } diff --git a/frontend/src/services/abstractService.ts b/frontend/src/services/abstractService.ts index a09507101..454cd08e8 100644 --- a/frontend/src/services/abstractService.ts +++ b/frontend/src/services/abstractService.ts @@ -1,5 +1,4 @@ -import {AuthenticatedHTTPFactory} from '@/helpers/fetcher' -import type {Method} from 'axios' +import {AuthenticatedHTTPFactory, type Method} from '@/helpers/fetcher' import {objectToSnakeCase} from '@/helpers/case' import AbstractModel from '@/models/abstractModel' @@ -74,7 +73,7 @@ export default abstract class AbstractService { switch (config.method) { - case 'post': + case 'POST': if (this.useUpdateInterceptor()) { config.data = this.beforeUpdate(config.data) if(this.autoTransformBeforePost()) { @@ -82,7 +81,7 @@ export default abstract class AbstractService