You've already forked agentic-coding-workflow
feat(web): fetch-based api client with 401 handling and tests
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
import { request } from './client'
|
||||||
|
import type { User } from '@/stores/auth'
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
user: User
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
request<LoginResponse>('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { email, password },
|
||||||
|
}),
|
||||||
|
me: () => request<User>('/api/v1/auth/me'),
|
||||||
|
logout: () => request<void>('/api/v1/auth/logout', { method: 'POST' }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { ApiException, type ApiError } from './types'
|
||||||
|
|
||||||
|
const BASE = '' // 同源;dev 走 vite proxy
|
||||||
|
|
||||||
|
interface RequestOptions {
|
||||||
|
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
||||||
|
body?: unknown
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
let authTokenProvider: () => string | null = () => null
|
||||||
|
let onUnauthorized: () => void = () => {}
|
||||||
|
|
||||||
|
export function configure(opts: {
|
||||||
|
tokenProvider: () => string | null
|
||||||
|
onUnauthorized: () => void
|
||||||
|
}) {
|
||||||
|
authTokenProvider = opts.tokenProvider
|
||||||
|
onUnauthorized = opts.onUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-Request-ID': crypto.randomUUID(),
|
||||||
|
}
|
||||||
|
const token = authTokenProvider()
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
|
||||||
|
const resp = await fetch(BASE + path, {
|
||||||
|
method: opts.method || 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
signal: opts.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
onUnauthorized()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 204) {
|
||||||
|
return undefined as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await resp.text()
|
||||||
|
const data = text ? JSON.parse(text) : null
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new ApiException(
|
||||||
|
resp.status,
|
||||||
|
(data as ApiError) || { code: 'unknown', message: resp.statusText },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { request } from './client'
|
||||||
|
import type { Notification } from '@/stores/notifications'
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
list: (unreadOnly = false, limit = 50) =>
|
||||||
|
request<{ items: Notification[] }>(
|
||||||
|
`/api/v1/notifications?unread=${unreadOnly}&limit=${limit}`,
|
||||||
|
),
|
||||||
|
unreadCount: () =>
|
||||||
|
request<{ count: number }>('/api/v1/notifications/unread-count'),
|
||||||
|
markRead: (id: string) =>
|
||||||
|
request<void>(`/api/v1/notifications/${id}/read`, { method: 'PATCH' }),
|
||||||
|
markAllRead: () =>
|
||||||
|
request<void>('/api/v1/notifications/read-all', { method: 'PATCH' }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export interface ApiError {
|
||||||
|
code: string
|
||||||
|
message: string
|
||||||
|
fields?: Record<string, unknown>
|
||||||
|
request_id?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiException extends Error {
|
||||||
|
status: number
|
||||||
|
body: ApiError
|
||||||
|
|
||||||
|
constructor(status: number, body: ApiError) {
|
||||||
|
super(body.message || `HTTP ${status}`)
|
||||||
|
this.status = status
|
||||||
|
this.body = body
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { configure, request } from '@/api/client'
|
||||||
|
import { ApiException } from '@/api/types'
|
||||||
|
|
||||||
|
describe('api client', () => {
|
||||||
|
let unauthorizedCalled = 0
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
unauthorizedCalled = 0
|
||||||
|
configure({
|
||||||
|
tokenProvider: () => 'tok',
|
||||||
|
onUnauthorized: () => {
|
||||||
|
unauthorizedCalled++
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('attaches bearer token and parses JSON on 200', async () => {
|
||||||
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ x: 1 }), { status: 200 }),
|
||||||
|
)
|
||||||
|
const out = await request<{ x: number }>('/api/test')
|
||||||
|
expect(out.x).toBe(1)
|
||||||
|
const init = fetchMock.mock.calls[0][1] as RequestInit
|
||||||
|
const headers = init.headers as Record<string, string>
|
||||||
|
expect(headers['Authorization']).toBe('Bearer tok')
|
||||||
|
expect(headers['X-Client-Request-ID']).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns undefined on 204', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 }))
|
||||||
|
const out = await request('/api/test', { method: 'DELETE' })
|
||||||
|
expect(out).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('invokes onUnauthorized for 401 and throws', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ code: 'unauthorized', message: 'no' }), { status: 401 }),
|
||||||
|
)
|
||||||
|
await expect(request('/api/test')).rejects.toBeInstanceOf(ApiException)
|
||||||
|
expect(unauthorizedCalled).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws ApiException with body for 4xx/5xx', async () => {
|
||||||
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
||||||
|
new Response(JSON.stringify({ code: 'invalid_input', message: 'bad' }), { status: 400 }),
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await request('/api/test')
|
||||||
|
throw new Error('should have thrown')
|
||||||
|
} catch (e) {
|
||||||
|
const ex = e as ApiException
|
||||||
|
expect(ex.status).toBe(400)
|
||||||
|
expect(ex.body.code).toBe('invalid_input')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
test: {
|
||||||
|
environment: 'happy-dom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user