feat: add typed API client for documents, upload and search
This commit is contained in:
parent
f8e3605ba9
commit
e4dbd34604
111
frontend/src/api/client.ts
Normal file
111
frontend/src/api/client.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
export interface DocumentFile {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
modifiedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentListResponse {
|
||||||
|
folders: string[];
|
||||||
|
files: DocumentFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DocumentContent {
|
||||||
|
key: string;
|
||||||
|
content: string;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '';
|
||||||
|
|
||||||
|
function encodeKeyPath(key: string): string {
|
||||||
|
return key
|
||||||
|
.split('/')
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listDocuments(path: string): Promise<DocumentListResponse> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/documents?path=${encodeURIComponent(path)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to list documents: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocument(key: string): Promise<DocumentContent> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to get document: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SaveDocumentResponse {
|
||||||
|
key: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveDocument(
|
||||||
|
key: string,
|
||||||
|
content: string,
|
||||||
|
contentType: string
|
||||||
|
): Promise<SaveDocumentResponse> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content, contentType }),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to save document: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDocument(key: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/documents/${encodeKeyPath(key)}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to delete document: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadDocumentResponse {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadDocument(file: File, path: string): Promise<UploadDocumentResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('path', path);
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to upload document: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResult {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
path: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SearchResponse {
|
||||||
|
results: SearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function search(query: string): Promise<SearchResponse> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to search: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
111
frontend/tests/api/client.test.ts
Normal file
111
frontend/tests/api/client.test.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { listDocuments, getDocument, saveDocument, deleteDocument, uploadDocument, search } from '../../src/api/client';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listDocuments calls GET /api/documents with the path query param and returns parsed JSON', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
folders: ['产品文档'],
|
||||||
|
files: [{ key: '产品文档/架构设计.md', name: '架构设计.md', size: 512, modifiedAt: '2026-08-01T10:00:00Z' }],
|
||||||
|
};
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await listDocuments('产品文档');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(`/api/documents?path=${encodeURIComponent('产品文档')}`);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listDocuments throws when the response is not ok', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await expect(listDocuments('')).rejects.toThrow('Failed to list documents: 500');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getDocument calls GET /api/documents/{key} preserving slashes in the key', async () => {
|
||||||
|
const mockResponse = { key: '产品文档/架构设计.md', content: '# 架构设计', contentType: 'text/markdown' };
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await getDocument('产品文档/架构设计.md');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saveDocument calls PUT /api/documents/{key} with content and contentType', async () => {
|
||||||
|
const mockResponse = { key: '产品文档/架构设计.md', updatedAt: '2026-08-01T10:05:00Z' };
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await saveDocument('产品文档/架构设计.md', '# 新内容', 'text/markdown');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: '# 新内容', contentType: 'text/markdown' }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleteDocument calls DELETE /api/documents/{key} and resolves with no value', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 204 }) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await expect(deleteDocument('产品文档/架构设计.md')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
`/api/documents/${encodeURIComponent('产品文档')}/${encodeURIComponent('架构设计.md')}`,
|
||||||
|
{ method: 'DELETE' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uploadDocument posts a multipart form with file and path fields', async () => {
|
||||||
|
const mockResponse = { key: '产品文档/新文档.md', name: '新文档.md', size: 42 };
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
const file = new File(['# 新文档'], '新文档.md', { type: 'text/markdown' });
|
||||||
|
|
||||||
|
const result = await uploadDocument(file, '产品文档');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(url).toBe('/api/upload');
|
||||||
|
expect(init.method).toBe('POST');
|
||||||
|
const body = init.body as FormData;
|
||||||
|
expect(body.get('file')).toBe(file);
|
||||||
|
expect(body.get('path')).toBe('产品文档');
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search calls GET /api/search with the q query param and returns parsed JSON', async () => {
|
||||||
|
const mockResponse = {
|
||||||
|
results: [
|
||||||
|
{ key: '产品文档/架构设计.md', title: '架构设计.md', path: '产品文档', snippet: '...<em>检索</em>...' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse),
|
||||||
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
|
const result = await search('检索');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(`/api/search?q=${encodeURIComponent('检索')}`);
|
||||||
|
expect(result).toEqual(mockResponse);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user