Improve API client base URL validation
This commit is contained in:
50
src/lib/apiClient.test.ts
Normal file
50
src/lib/apiClient.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { apiGet } from './apiClient';
|
||||||
|
|
||||||
|
const originalEnv = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL = originalEnv;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('apiClient', () => {
|
||||||
|
it('throws when base URL is missing', async () => {
|
||||||
|
delete process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
await expect(apiGet('/internal/agents')).rejects.toThrow('NEXT_PUBLIC_API_BASE_URL is not configured');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes the request URL and returns JSON', async () => {
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL = 'https://api.example.com/';
|
||||||
|
const payload = { ok: true };
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => payload
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const result = await apiGet('internal/agents');
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('https://api.example.com/internal/agents', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { 'content-type': 'application/json' }
|
||||||
|
});
|
||||||
|
expect(result).toEqual(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('wraps network failures with URL context', async () => {
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL = 'https://api.example.com';
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connection refused')));
|
||||||
|
|
||||||
|
await expect(apiGet('/internal/agents')).rejects.toThrow(
|
||||||
|
'Failed to reach API at https://api.example.com/internal/agents: connection refused'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,35 @@
|
|||||||
|
function buildApiUrl(path: string): string {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error('NEXT_PUBLIC_API_BASE_URL is not configured. Set it to your API endpoint.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBase = baseUrl.replace(/\/$/, '');
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
return `${normalizedBase}${normalizedPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiGet<T>(path: string): Promise<T> {
|
export async function apiGet<T>(path: string): Promise<T> {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
const url = buildApiUrl(path);
|
||||||
const res = await fetch(`${baseUrl}${path}`, {
|
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json'
|
'content-type': 'application/json'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
throw new Error(`Failed to reach API at ${url}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error(`API error ${res.status}: ${text}`);
|
const body = text || res.statusText;
|
||||||
|
throw new Error(`API error ${res.status} from ${url}: ${body}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
|
|||||||
Reference in New Issue
Block a user