Compare commits
4 Commits
main
...
feature/re
| Author | SHA1 | Date |
|---|---|---|
|
|
60057ccf6b | |
|
|
97c3e59821 | |
|
|
a747ed9ac2 | |
|
|
ee8bd6c3c9 |
|
|
@ -79,7 +79,6 @@
|
||||||
"@tiptap/vue-3": "2.26.1",
|
"@tiptap/vue-3": "2.26.1",
|
||||||
"@vueuse/core": "13.5.0",
|
"@vueuse/core": "13.5.0",
|
||||||
"@vueuse/router": "13.5.0",
|
"@vueuse/router": "13.5.0",
|
||||||
"axios": "1.10.0",
|
|
||||||
"blurhash": "2.0.5",
|
"blurhash": "2.0.5",
|
||||||
"bulma-css-variables": "0.9.33",
|
"bulma-css-variables": "0.9.33",
|
||||||
"change-case": "5.4.4",
|
"change-case": "5.4.4",
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,6 @@ importers:
|
||||||
'@vueuse/router':
|
'@vueuse/router':
|
||||||
specifier: 13.5.0
|
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))
|
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:
|
blurhash:
|
||||||
specifier: 2.0.5
|
specifier: 2.0.5
|
||||||
version: 2.0.5
|
version: 2.0.5
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
|
import {AuthenticatedHTTPFactory, type HttpResponse} from '@/helpers/fetcher'
|
||||||
import type {AxiosResponse} from 'axios'
|
|
||||||
|
|
||||||
let savedToken: string | null = null
|
let savedToken: string | null = null
|
||||||
|
|
||||||
|
|
@ -37,7 +36,7 @@ export const removeToken = () => {
|
||||||
/**
|
/**
|
||||||
* Refreshes an auth token while ensuring it is updated everywhere.
|
* 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()
|
const HTTP = AuthenticatedHTTPFactory()
|
||||||
try {
|
try {
|
||||||
const response = await HTTP.post('user/token')
|
const response = await HTTP.post('user/token')
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,250 @@
|
||||||
import axios from 'axios'
|
|
||||||
import {getToken} from '@/helpers/auth'
|
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() {
|
export function HTTPFactory() {
|
||||||
const instance = axios.create({baseURL: window.API_URL})
|
return new HttpClient(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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthenticatedHTTPFactory() {
|
export function AuthenticatedHTTPFactory() {
|
||||||
const instance = HTTPFactory()
|
const instance = HTTPFactory()
|
||||||
|
instance.interceptors.request.use(config => {
|
||||||
instance.interceptors.request.use((config) => {
|
|
||||||
config.headers = {
|
|
||||||
...config.headers,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the default auth header if we have a token
|
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (token !== null) {
|
config.headers = {
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
'Content-Type': 'application/json',
|
||||||
|
...config.headers,
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
return instance
|
return instance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FetchHttpInstance = HttpClient
|
||||||
|
|
||||||
|
export default HttpClient
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import 'virtual:vite-plugin-sentry/sentry-config'
|
import 'virtual:vite-plugin-sentry/sentry-config'
|
||||||
import type {App} from 'vue'
|
import type {App} from 'vue'
|
||||||
import type {Router} from 'vue-router'
|
import type {Router} from 'vue-router'
|
||||||
import {AxiosError} from 'axios'
|
import {HttpError} from '@/helpers/fetcher'
|
||||||
|
|
||||||
export default async function setupSentry(app: App, router: Router) {
|
export default async function setupSentry(app: App, router: Router) {
|
||||||
const Sentry = await import('@sentry/vue')
|
const Sentry = await import('@sentry/vue')
|
||||||
|
|
@ -21,9 +21,10 @@ export default async function setupSentry(app: App, router: Router) {
|
||||||
tracesSampleRate: 1.0,
|
tracesSampleRate: 1.0,
|
||||||
beforeSend(event, hint) {
|
beforeSend(event, hint) {
|
||||||
|
|
||||||
if ((typeof hint.originalException?.code !== 'undefined' &&
|
if (
|
||||||
typeof hint.originalException?.message !== 'undefined')
|
(typeof hint.originalException?.code !== 'undefined' && typeof hint.originalException?.message !== 'undefined')
|
||||||
|| hint.originalException instanceof AxiosError) {
|
|| hint.originalException instanceof HttpError
|
||||||
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
|
import {AuthenticatedHTTPFactory, type Method} from '@/helpers/fetcher'
|
||||||
import type {Method} from 'axios'
|
|
||||||
|
|
||||||
import {objectToSnakeCase} from '@/helpers/case'
|
import {objectToSnakeCase} from '@/helpers/case'
|
||||||
import AbstractModel from '@/models/abstractModel'
|
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
|
// Set the interceptors to process every request
|
||||||
this.http.interceptors.request.use((config) => {
|
this.http.interceptors.request.use((config) => {
|
||||||
switch (config.method) {
|
switch (config.method) {
|
||||||
case 'post':
|
case 'POST':
|
||||||
if (this.useUpdateInterceptor()) {
|
if (this.useUpdateInterceptor()) {
|
||||||
config.data = this.beforeUpdate(config.data)
|
config.data = this.beforeUpdate(config.data)
|
||||||
if(this.autoTransformBeforePost()) {
|
if(this.autoTransformBeforePost()) {
|
||||||
|
|
@ -82,7 +81,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'put':
|
case 'PUT':
|
||||||
if (this.useCreateInterceptor()) {
|
if (this.useCreateInterceptor()) {
|
||||||
config.data = this.beforeCreate(config.data)
|
config.data = this.beforeCreate(config.data)
|
||||||
if(this.autoTransformBeforePut()) {
|
if(this.autoTransformBeforePut()) {
|
||||||
|
|
@ -90,7 +89,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
case 'delete':
|
case 'DELETE':
|
||||||
if (this.useDeleteInterceptor()) {
|
if (this.useDeleteInterceptor()) {
|
||||||
config.data = this.beforeDelete(config.data)
|
config.data = this.beforeDelete(config.data)
|
||||||
if(this.autoTransformBeforeDelete()) {
|
if(this.autoTransformBeforeDelete()) {
|
||||||
|
|
@ -318,7 +317,7 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
|
||||||
}
|
}
|
||||||
|
|
||||||
async getBlobUrl(url : string, method : Method = 'GET', data = {}) {
|
async getBlobUrl(url : string, method : Method = 'GET', data = {}) {
|
||||||
const response = await this.http({
|
const response = await this.http.request({
|
||||||
url,
|
url,
|
||||||
method,
|
method,
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export default class BackgroundUnsplashService extends AbstractService<IBackgrou
|
||||||
}
|
}
|
||||||
|
|
||||||
async thumb(model) {
|
async thumb(model) {
|
||||||
const response = await this.http({
|
const response = await this.http.request({
|
||||||
url: `/backgrounds/unsplash/images/${model.id}/thumb`,
|
url: `/backgrounds/unsplash/images/${model.id}/thumb`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ export default class ProjectService extends AbstractService<IProject> {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.http({
|
const response = await this.http.request({
|
||||||
url: `/projects/${project.id}/background`,
|
url: `/projects/${project.id}/background`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export default class TotpService extends AbstractService<ITotp> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async qrcode() {
|
async qrcode() {
|
||||||
const response = await this.http({
|
const response = await this.http.request({
|
||||||
url: `${this.urlPrefix}/qrcode`,
|
url: `${this.urlPrefix}/qrcode`,
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue