Skip to content

Commit 2a7fc6f

Browse files
Your Nameclaude
andcommitted
feat: integration service for third-party APIs (#318)
Closes #318 - Add IntegrationService as central registry for third-party API connectors - Add ApiConnector wrapping ApiClient with per-call metrics tracking (totalRequests, successCount, errorCount, totalLatencyMs, lastRequestAt) - Add IntegrationMonitor for periodic health-check polling with status recording (active/error) per integration - Export all types and classes from api/src/index.ts - Add MSW mock handlers for third-party integration endpoints - Add 26 integration tests covering HTTP methods, metrics, auth headers, health monitoring, and IntegrationService lifecycle (register, connector, deregister, event log, end-to-end) - Fix pre-existing TypeScript errors: missing Event fields in mocks, missing Trade import, missing server export in mocks.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 04f4980 commit 2a7fc6f

7 files changed

Lines changed: 638 additions & 1 deletion

File tree

api/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,19 @@ export type {
109109
RetryConfig,
110110
} from './types';
111111
export type { ApiConfig };
112+
export {
113+
ApiConnector,
114+
IntegrationMonitor,
115+
IntegrationService,
116+
} from './integration';
117+
export type {
118+
IntegrationConfig,
119+
IntegrationEvent,
120+
IntegrationHealth,
121+
IntegrationMetrics,
122+
IntegrationProvider,
123+
IntegrationStatus,
124+
} from './integration';
112125
export type {
113126
OperationPerformanceSummary,
114127
PerformanceAlert,

api/src/integration.test.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { rest } from 'msw';
2+
import { server } from './mocks';
3+
import {
4+
ApiConnector,
5+
IntegrationConfig,
6+
IntegrationMonitor,
7+
IntegrationService,
8+
} from './integration';
9+
10+
// ── Shared fixture ────────────────────────────────────────────────────────────
11+
12+
const BASE_CONFIG: IntegrationConfig = {
13+
id: 'test-integration',
14+
name: 'Test Integration',
15+
provider: 'custom',
16+
baseUrl: 'https://api.example-integration.com',
17+
apiKey: 'secret-token',
18+
};
19+
20+
// ── ApiConnector ──────────────────────────────────────────────────────────────
21+
22+
describe('ApiConnector', () => {
23+
describe('HTTP methods', () => {
24+
it('GET returns data from the third-party API', async () => {
25+
const connector = new ApiConnector(BASE_CONFIG);
26+
const data = await connector.get<{ items: unknown[] }>('/resource');
27+
expect(data.items).toHaveLength(1);
28+
});
29+
30+
it('POST creates a resource', async () => {
31+
const connector = new ApiConnector(BASE_CONFIG);
32+
const data = await connector.post<{ id: string; name: string }>('/resource', { name: 'foo' });
33+
expect(data.id).toBe('r-new');
34+
expect(data.name).toBe('foo');
35+
});
36+
37+
it('PATCH updates a resource', async () => {
38+
const connector = new ApiConnector(BASE_CONFIG);
39+
const data = await connector.patch<{ id: string; name: string }>('/resource/r1', { name: 'bar' });
40+
expect(data.id).toBe('r1');
41+
expect(data.name).toBe('bar');
42+
});
43+
44+
it('DELETE removes a resource without error', async () => {
45+
const connector = new ApiConnector(BASE_CONFIG);
46+
await expect(connector.delete('/resource/r1')).resolves.not.toThrow();
47+
});
48+
});
49+
50+
describe('metrics tracking', () => {
51+
it('starts with zero counts', () => {
52+
const connector = new ApiConnector(BASE_CONFIG);
53+
const m = connector.getMetrics();
54+
expect(m.totalRequests).toBe(0);
55+
expect(m.successCount).toBe(0);
56+
expect(m.errorCount).toBe(0);
57+
});
58+
59+
it('increments success counts after a successful call', async () => {
60+
const connector = new ApiConnector(BASE_CONFIG);
61+
await connector.get('/resource');
62+
const m = connector.getMetrics();
63+
expect(m.totalRequests).toBe(1);
64+
expect(m.successCount).toBe(1);
65+
expect(m.errorCount).toBe(0);
66+
expect(m.lastRequestAt).not.toBeNull();
67+
});
68+
69+
it('increments error counts when the call fails', async () => {
70+
const connector = new ApiConnector(BASE_CONFIG);
71+
await expect(connector.get('/error')).rejects.toBeDefined();
72+
const m = connector.getMetrics();
73+
expect(m.totalRequests).toBe(1);
74+
expect(m.errorCount).toBe(1);
75+
expect(m.successCount).toBe(0);
76+
});
77+
78+
it('accumulates latency across multiple calls', async () => {
79+
const connector = new ApiConnector(BASE_CONFIG);
80+
await connector.get('/resource');
81+
await connector.get('/resource');
82+
const m = connector.getMetrics();
83+
expect(m.totalRequests).toBe(2);
84+
expect(m.successCount).toBe(2);
85+
expect(m.totalLatencyMs).toBeGreaterThanOrEqual(0);
86+
});
87+
});
88+
89+
describe('auth headers', () => {
90+
it('attaches Authorization header when apiKey is set', async () => {
91+
let capturedAuthHeader: string | undefined;
92+
93+
server.use(
94+
rest.get('https://api.example-integration.com/resource', (req, res, ctx) => {
95+
capturedAuthHeader = req.headers.get('Authorization') ?? undefined;
96+
return res(ctx.json({ items: [] }));
97+
})
98+
);
99+
100+
const connector = new ApiConnector(BASE_CONFIG);
101+
await connector.get('/resource');
102+
103+
expect(capturedAuthHeader).toBe('Bearer secret-token');
104+
});
105+
106+
it('merges extra static headers from config', async () => {
107+
let capturedHeader: string | undefined;
108+
109+
server.use(
110+
rest.get('https://api.example-integration.com/resource', (req, res, ctx) => {
111+
capturedHeader = req.headers.get('X-Custom') ?? undefined;
112+
return res(ctx.json({ items: [] }));
113+
})
114+
);
115+
116+
const connector = new ApiConnector({
117+
...BASE_CONFIG,
118+
headers: { 'X-Custom': 'my-value' },
119+
});
120+
await connector.get('/resource');
121+
122+
expect(capturedHeader).toBe('my-value');
123+
});
124+
});
125+
});
126+
127+
// ── IntegrationMonitor ────────────────────────────────────────────────────────
128+
129+
describe('IntegrationMonitor', () => {
130+
it('records a healthy status after a successful health check', async () => {
131+
const monitor = new IntegrationMonitor();
132+
const connector = new ApiConnector(BASE_CONFIG);
133+
134+
monitor.start(connector, '/health', 999_999);
135+
await new Promise((r) => setTimeout(r, 100));
136+
137+
const health = monitor.getHealth(BASE_CONFIG.id);
138+
expect(health).toBeDefined();
139+
expect(health?.status).toBe('active');
140+
expect(health?.latencyMs).toBeGreaterThanOrEqual(0);
141+
monitor.stopAll();
142+
});
143+
144+
it('records an error status when the health endpoint fails', async () => {
145+
server.use(
146+
rest.get('https://api.example-integration.com/health', (_req, res, ctx) =>
147+
res(ctx.status(503), ctx.json({ message: 'Service unavailable' }))
148+
)
149+
);
150+
151+
const monitor = new IntegrationMonitor();
152+
const connector = new ApiConnector(BASE_CONFIG);
153+
monitor.start(connector, '/health', 999_999);
154+
await new Promise((r) => setTimeout(r, 100));
155+
156+
const health = monitor.getHealth(BASE_CONFIG.id);
157+
expect(health?.status).toBe('error');
158+
expect(health?.error).toBeDefined();
159+
monitor.stopAll();
160+
});
161+
162+
it('getAllHealth returns records for all monitored integrations', async () => {
163+
const monitor = new IntegrationMonitor();
164+
const c1 = new ApiConnector({ ...BASE_CONFIG, id: 'int-a' });
165+
const c2 = new ApiConnector({ ...BASE_CONFIG, id: 'int-b' });
166+
monitor.start(c1, '/health', 999_999);
167+
monitor.start(c2, '/health', 999_999);
168+
await new Promise((r) => setTimeout(r, 100));
169+
expect(monitor.getAllHealth()).toHaveLength(2);
170+
monitor.stopAll();
171+
});
172+
});
173+
174+
// ── IntegrationService ────────────────────────────────────────────────────────
175+
176+
describe('IntegrationService', () => {
177+
let service: IntegrationService;
178+
179+
beforeEach(() => {
180+
service = new IntegrationService();
181+
});
182+
183+
afterEach(() => {
184+
service.clear();
185+
});
186+
187+
describe('register', () => {
188+
it('registers a connector and returns it', () => {
189+
const connector = service.register(BASE_CONFIG);
190+
expect(connector).toBeInstanceOf(ApiConnector);
191+
});
192+
193+
it('throws when registering a duplicate id', () => {
194+
service.register(BASE_CONFIG);
195+
expect(() => service.register(BASE_CONFIG)).toThrow(/already registered/);
196+
});
197+
198+
it('throws when the integration is disabled', () => {
199+
expect(() =>
200+
service.register({ ...BASE_CONFIG, id: 'disabled', enabled: false })
201+
).toThrow(/disabled/);
202+
});
203+
});
204+
205+
describe('connector', () => {
206+
it('returns the registered connector', () => {
207+
service.register(BASE_CONFIG);
208+
expect(service.connector(BASE_CONFIG.id)).toBeInstanceOf(ApiConnector);
209+
});
210+
211+
it('throws when the id is not registered', () => {
212+
expect(() => service.connector('unknown')).toThrow(/not registered/);
213+
});
214+
});
215+
216+
describe('has', () => {
217+
it('returns true for a registered integration', () => {
218+
service.register(BASE_CONFIG);
219+
expect(service.has(BASE_CONFIG.id)).toBe(true);
220+
});
221+
222+
it('returns false for an unregistered id', () => {
223+
expect(service.has('not-here')).toBe(false);
224+
});
225+
});
226+
227+
describe('deregister', () => {
228+
it('removes the integration', () => {
229+
service.register(BASE_CONFIG);
230+
service.deregister(BASE_CONFIG.id);
231+
expect(service.has(BASE_CONFIG.id)).toBe(false);
232+
});
233+
234+
it('allows re-registration after deregister', () => {
235+
service.register(BASE_CONFIG);
236+
service.deregister(BASE_CONFIG.id);
237+
expect(() => service.register(BASE_CONFIG)).not.toThrow();
238+
});
239+
});
240+
241+
describe('getMetrics', () => {
242+
it('returns empty array when no integrations are registered', () => {
243+
expect(service.getMetrics()).toEqual([]);
244+
});
245+
246+
it('returns metrics for all registered integrations', () => {
247+
service.register(BASE_CONFIG);
248+
service.register({ ...BASE_CONFIG, id: 'second' });
249+
const metrics = service.getMetrics();
250+
expect(metrics).toHaveLength(2);
251+
expect(metrics.map((m) => m.integrationId)).toContain(BASE_CONFIG.id);
252+
});
253+
});
254+
255+
describe('getEventLog', () => {
256+
it('logs a REGISTER event when an integration is registered', () => {
257+
service.register(BASE_CONFIG);
258+
const log = service.getEventLog();
259+
expect(log).toHaveLength(1);
260+
expect(log[0].method).toBe('REGISTER');
261+
expect(log[0].integrationId).toBe(BASE_CONFIG.id);
262+
});
263+
});
264+
265+
describe('end-to-end', () => {
266+
it('calls the third-party API through the service', async () => {
267+
service.register(BASE_CONFIG);
268+
const connector = service.connector(BASE_CONFIG.id);
269+
const data = await connector.get<{ items: unknown[] }>('/resource');
270+
expect(data.items).toHaveLength(1);
271+
272+
const metrics = service.getMetrics().find((m) => m.integrationId === BASE_CONFIG.id);
273+
expect(metrics?.successCount).toBe(1);
274+
});
275+
});
276+
});

0 commit comments

Comments
 (0)