Compare commits

...

4 Commits

Author SHA1 Message Date
kolaente 60057ccf6b
fix: base url 2025-07-22 20:01:39 +02:00
kolaente 97c3e59821
do not try to pass request body to get request 2025-07-22 20:01:39 +02:00
kolaente a747ed9ac2
fix 2025-07-22 20:01:39 +02:00
kolaente ee8bd6c3c9
feat: replace axios with direct fetch usage 2025-07-22 20:01:39 +02:00
9 changed files with 253 additions and 44 deletions

View File

@ -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",

View File

@ -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

View File

@ -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<AxiosResponse> {
export async function refreshToken(persist: boolean): Promise<HttpResponse> {
const HTTP = AuthenticatedHTTPFactory()
try {
const response = await HTTP.post('user/token')

View File

@ -1,36 +1,250 @@
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<string, string>
params?: Record<string, unknown>
data?: unknown
baseURL?: string
responseType?: 'json' | 'blob' | 'text'
onUploadProgress?: (progress: {progress: number}) => void
transformRequest?: (data: unknown) => unknown
}
export interface HttpResponse<T = unknown> {
data: T
headers: Record<string, string>
status: number
}
export class HttpError extends Error {
status?: number
data?: unknown
response?: Response
}
class InterceptorManager<T> {
private handlers: Array<(arg: T) => Promise<T> | T> = []
use(handler: (arg: T) => Promise<T> | T) {
this.handlers.push(handler)
}
async run(arg: T): Promise<T> {
for (const handler of this.handlers) {
arg = await handler(arg)
}
return arg
}
}
class HttpClient {
private baseURL: string
interceptors = {
request: new InterceptorManager<RequestConfig>(),
response: new InterceptorManager<HttpResponse>(),
}
constructor(baseURL: string) {
this.baseURL = baseURL
}
private buildUrl(config: RequestConfig) {
let url = config.url
if (!url.startsWith('http')) {
const baseURL = config.baseURL ?? this.baseURL ?? ''
url = baseURL + (baseURL.endsWith('/') || url.startsWith('/') ? '' : '/') + url
}
if (config.params) {
const qs = new URLSearchParams(config.params as Record<string, string>).toString()
if (qs) {
url += (url.includes('?') ? '&' : '?') + qs
}
}
return url
}
private parseHeaders(headers: Headers): Record<string, string> {
const result: Record<string, string> = {}
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<unknown> {
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<HttpResponse> {
const url = this.buildUrl(config)
const init: RequestInit = {method: config.method, headers: config.headers}
// GET and HEAD requests cannot have a body
const method = config.method?.toUpperCase()
if (method !== 'GET' && method !== 'HEAD') {
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<string, string>)['Content-Type']) {
(init.headers as Record<string, string>)['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<HttpResponse> {
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<string, string> = {}
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)
}
// GET and HEAD requests cannot have a body
const method = config.method?.toUpperCase()
if (method === 'GET' || method === 'HEAD') {
xhr.send()
} else {
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<HttpResponse> {
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<RequestConfig> = {}) {
return this.request({...config, url, method: 'GET'})
}
delete(url: string, data?: unknown, config: Partial<RequestConfig> = {}) {
return this.request({...config, url, method: 'DELETE', data})
}
post(url: string, data?: unknown, config: Partial<RequestConfig> = {}) {
return this.request({...config, url, method: 'POST', data})
}
put(url: string, data?: unknown, config: Partial<RequestConfig> = {}) {
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

View File

@ -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
}

View File

@ -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<Model extends IAbstract = IAbstrac
// Set the interceptors to process every request
this.http.interceptors.request.use((config) => {
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<Model extends IAbstract = IAbstrac
}
}
break
case 'put':
case 'PUT':
if (this.useCreateInterceptor()) {
config.data = this.beforeCreate(config.data)
if(this.autoTransformBeforePut()) {
@ -90,7 +89,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
}
}
break
case 'delete':
case 'DELETE':
if (this.useDeleteInterceptor()) {
config.data = this.beforeDelete(config.data)
if(this.autoTransformBeforeDelete()) {
@ -318,7 +317,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
}
async getBlobUrl(url : string, method : Method = 'GET', data = {}) {
const response = await this.http({
const response = await this.http.request({
url,
method,
responseType: 'blob',

View File

@ -20,7 +20,7 @@ export default class BackgroundUnsplashService extends AbstractService<IBackgrou
}
async thumb(model) {
const response = await this.http({
const response = await this.http.request({
url: `/backgrounds/unsplash/images/${model.id}/thumb`,
method: 'GET',
responseType: 'blob',

View File

@ -44,7 +44,7 @@ export default class ProjectService extends AbstractService<IProject> {
return ''
}
const response = await this.http({
const response = await this.http.request({
url: `/projects/${project.id}/background`,
method: 'GET',
responseType: 'blob',

View File

@ -28,7 +28,7 @@ export default class TotpService extends AbstractService<ITotp> {
}
async qrcode() {
const response = await this.http({
const response = await this.http.request({
url: `${this.urlPrefix}/qrcode`,
method: 'GET',
responseType: 'blob',