feat(tracker-web): expose agent v1 proxy
This commit is contained in:
parent
ccfbfd194a
commit
6e023f3bdc
96
dashboards/tracker-web/src/__tests__/agent-proxy.test.ts
Normal file
96
dashboards/tracker-web/src/__tests__/agent-proxy.test.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Tests for /api/agent/v1/[...path] — agent-facing platform proxy.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
|
||||||
|
import { GET, POST } from '@/app/api/agent/v1/[...path]/route';
|
||||||
|
|
||||||
|
function mockNextRequest(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: string,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): NextRequest {
|
||||||
|
const headerMap = new Map(Object.entries(headers || {}));
|
||||||
|
return {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
get: (key: string) => headerMap.get(key.toLowerCase()) || headerMap.get(key) || null,
|
||||||
|
},
|
||||||
|
nextUrl: {
|
||||||
|
searchParams: new URLSearchParams('limit=10'),
|
||||||
|
},
|
||||||
|
text: async () => body || '',
|
||||||
|
} as unknown as NextRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('agent v1 proxy', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
process.env.PLATFORM_API_URL = 'http://localhost:4003';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies agent list requests with product and agent key headers', async () => {
|
||||||
|
mockFetch.mockResolvedValue({ status: 200, text: async () => JSON.stringify({ items: [] }) });
|
||||||
|
|
||||||
|
const req = mockNextRequest('GET', 'items/assigned', undefined, {
|
||||||
|
'x-agent-key': 'agent-key-1',
|
||||||
|
'x-product-id': 'tracker',
|
||||||
|
});
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ path: ['items', 'assigned'] }) });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/agent/v1/items/assigned?limit=10'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'GET',
|
||||||
|
headers: expect.objectContaining({
|
||||||
|
'x-agent-key': 'agent-key-1',
|
||||||
|
'x-product-id': 'tracker',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('proxies agent mutation requests with JSON body', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
status: 201,
|
||||||
|
text: async () => JSON.stringify({ id: 'item_1' }),
|
||||||
|
});
|
||||||
|
const body = JSON.stringify({ title: 'Agent-created item' });
|
||||||
|
const req = mockNextRequest('POST', 'items', body, { authorization: 'Bearer token' });
|
||||||
|
|
||||||
|
const res = await POST(req, { params: Promise.resolve({ path: ['items'] }) });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('/api/agent/v1/items'),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 502 when the agent API is unavailable', async () => {
|
||||||
|
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
|
|
||||||
|
const req = mockNextRequest('GET', 'items/assigned');
|
||||||
|
const res = await GET(req, { params: Promise.resolve({ path: ['items', 'assigned'] }) });
|
||||||
|
|
||||||
|
expect(res.status).toBe(502);
|
||||||
|
await expect(res.json()).resolves.toEqual({ error: 'Agent API unavailable' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Agent API v1 proxy.
|
||||||
|
*
|
||||||
|
* Exposes /api/agent/v1/* from tracker-web and forwards it to platform-service.
|
||||||
|
* This gives coding agents a stable product-facing surface while the backend
|
||||||
|
* owns auth, claim/close semantics, and persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const PLATFORM_API = process.env.PLATFORM_API_URL || 'http://localhost:4003';
|
||||||
|
|
||||||
|
async function proxy(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
|
||||||
|
const { path } = await params;
|
||||||
|
const targetPath = `/api/agent/v1/${path.join('/')}`;
|
||||||
|
const url = new URL(targetPath, PLATFORM_API);
|
||||||
|
|
||||||
|
req.nextUrl.searchParams.forEach((value, key) => {
|
||||||
|
url.searchParams.set(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||||
|
const auth = req.headers.get('authorization');
|
||||||
|
const agentKey = req.headers.get('x-agent-key');
|
||||||
|
const productId = req.headers.get('x-product-id');
|
||||||
|
|
||||||
|
if (auth) headers.Authorization = auth;
|
||||||
|
if (agentKey) headers['x-agent-key'] = agentKey;
|
||||||
|
if (productId) headers['x-product-id'] = productId;
|
||||||
|
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||||
|
const body = await req.text();
|
||||||
|
if (body) fetchOptions.body = body;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), fetchOptions);
|
||||||
|
const data = await res.text();
|
||||||
|
|
||||||
|
return new NextResponse(data, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': res.headers?.get('content-type') || 'application/json' },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: 'Agent API unavailable' }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET = proxy;
|
||||||
|
export const POST = proxy;
|
||||||
|
export const PUT = proxy;
|
||||||
|
export const PATCH = proxy;
|
||||||
|
export const DELETE = proxy;
|
||||||
Loading…
Reference in New Issue
Block a user