Improve API client base URL validation

This commit is contained in:
Alexa Amundson
2025-11-23 17:54:00 -06:00
parent 9c4c17b4c4
commit 1e6520aba2
2 changed files with 78 additions and 8 deletions

50
src/lib/apiClient.test.ts Normal file
View 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'
);
});
});

View File

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