feat(rest): add SData 2.0 REST client with pagination and auth
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
267
src/clients/__tests__/rest-client.test.ts
Normal file
267
src/clients/__tests__/rest-client.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { RestClient } from '../rest-client.js';
|
||||
import type { SageConfig } from '../../types/index.js';
|
||||
|
||||
const mockConfig: SageConfig = {
|
||||
url: 'https://x3.example.com:8124',
|
||||
user: 'admin',
|
||||
password: 'secret123',
|
||||
endpoint: 'SEED',
|
||||
poolAlias: 'SEED',
|
||||
language: 'ENG',
|
||||
rejectUnauthorized: true,
|
||||
};
|
||||
|
||||
function jsonResponse(body: unknown, status = 200, statusText = 'OK'): Response {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText,
|
||||
headers: new Headers({ 'content-type': 'application/json' }),
|
||||
json: () => Promise.resolve(body),
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe('RestClient', () => {
|
||||
let client: RestClient;
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new RestClient(mockConfig);
|
||||
fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(jsonResponse({}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('auth header', () => {
|
||||
it('sends correct Basic auth on every request', async () => {
|
||||
await client.healthCheck();
|
||||
|
||||
const [, init] = fetchSpy.mock.calls[0];
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
const decoded = Buffer.from(headers['Authorization'].replace('Basic ', ''), 'base64').toString();
|
||||
expect(decoded).toBe('admin:secret123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('returns paginated results with records and pagination', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
$resources: [{ ITMREF: 'A001' }, { ITMREF: 'A002' }],
|
||||
$links: { $next: { $url: 'https://x3.example.com:8124/next-page' } },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await client.query({ entity: 'ITMMASTER' });
|
||||
|
||||
expect(result.records).toHaveLength(2);
|
||||
expect(result.pagination.returned).toBe(2);
|
||||
expect(result.pagination.hasMore).toBe(true);
|
||||
expect(result.pagination.nextUrl).toBe('https://x3.example.com:8124/next-page');
|
||||
});
|
||||
|
||||
it('uses nextUrl directly for cursor-based pagination', async () => {
|
||||
const nextUrl = 'https://x3.example.com:8124/api1/x3/erp/SEED/ITMMASTER?token=abc123';
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [{ ITMREF: 'B001' }] }));
|
||||
|
||||
await client.query({ entity: 'ITMMASTER', nextUrl });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith(nextUrl, expect.objectContaining({ method: 'GET' }));
|
||||
});
|
||||
|
||||
it('caps count at 200 when higher value requested', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] }));
|
||||
|
||||
await client.query({ entity: 'ITMMASTER', count: 500 });
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain('count=200');
|
||||
expect(calledUrl).not.toContain('count=500');
|
||||
});
|
||||
|
||||
it('defaults count to 20 when not specified', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] }));
|
||||
|
||||
await client.query({ entity: 'ITMMASTER' });
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain('count=20');
|
||||
});
|
||||
|
||||
it('returns empty results when $resources is missing', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
|
||||
|
||||
const result = await client.query({ entity: 'ITMMASTER' });
|
||||
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.pagination.returned).toBe(0);
|
||||
expect(result.pagination.hasMore).toBe(false);
|
||||
expect(result.pagination.nextUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes where, orderBy, select in URL when provided', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] }));
|
||||
|
||||
await client.query({
|
||||
entity: 'ITMMASTER',
|
||||
where: "ITMREF like 'A%'",
|
||||
orderBy: 'ITMREF asc',
|
||||
select: 'ITMREF,ITMDES1',
|
||||
});
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain('where=');
|
||||
expect(calledUrl).toContain('orderBy=');
|
||||
expect(calledUrl).toContain('select=');
|
||||
});
|
||||
|
||||
it('uses custom representation when provided', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({ $resources: [] }));
|
||||
|
||||
await client.query({ entity: 'ITMMASTER', representation: 'ITMCUSTOM' });
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain('representation=ITMCUSTOM.$query');
|
||||
});
|
||||
});
|
||||
|
||||
describe('read', () => {
|
||||
it('returns single record wrapped in { record }', async () => {
|
||||
const record = { ITMREF: 'A001', ITMDES1: 'Widget' };
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse(record));
|
||||
|
||||
const result = await client.read('ITMMASTER', 'A001');
|
||||
|
||||
expect(result.record).toEqual(record);
|
||||
});
|
||||
|
||||
it('builds correct URL with entity key and default representation', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
|
||||
|
||||
await client.read('ITMMASTER', 'A001');
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toBe(
|
||||
"https://x3.example.com:8124/api1/x3/erp/SEED/ITMMASTER('A001')?representation=ITMMASTER.$details",
|
||||
);
|
||||
});
|
||||
|
||||
it('uses custom representation when provided', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
|
||||
|
||||
await client.read('ITMMASTER', 'A001', 'ITMCUSTOM');
|
||||
|
||||
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
||||
expect(calledUrl).toContain('representation=ITMCUSTOM.$details');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listEntities', () => {
|
||||
it('returns array of entity names from $resources', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
$resources: [
|
||||
{ $name: 'ITMMASTER' },
|
||||
{ $name: 'SORDER' },
|
||||
{ $name: 'BPCUSTOMER' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const entities = await client.listEntities();
|
||||
|
||||
expect(entities).toEqual(['ITMMASTER', 'SORDER', 'BPCUSTOMER']);
|
||||
});
|
||||
|
||||
it('returns empty array when no $resources', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
|
||||
|
||||
const entities = await client.listEntities();
|
||||
|
||||
expect(entities).toEqual([]);
|
||||
});
|
||||
|
||||
it('filters out entries without names', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
$resources: [{ $name: 'ITMMASTER' }, { other: 'data' }, { $name: 'SORDER' }],
|
||||
}),
|
||||
);
|
||||
|
||||
const entities = await client.listEntities();
|
||||
|
||||
expect(entities).toEqual(['ITMMASTER', 'SORDER']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('healthCheck', () => {
|
||||
it('returns connected status with latency on success', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
|
||||
|
||||
const result = await client.healthCheck();
|
||||
|
||||
expect(result.status).toBe('connected');
|
||||
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('returns error status with message on failure', async () => {
|
||||
fetchSpy.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await client.healthCheck();
|
||||
|
||||
expect(result.status).toContain('error:');
|
||||
expect(result.status).toContain('ECONNREFUSED');
|
||||
expect(result.latencyMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws on 401 Unauthorized', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}, 401, 'Unauthorized'));
|
||||
|
||||
await expect(client.query({ entity: 'ITMMASTER' })).rejects.toThrow('HTTP 401: Unauthorized');
|
||||
});
|
||||
|
||||
it('throws on non-JSON response (login redirect)', async () => {
|
||||
fetchSpy.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: new Headers({ 'content-type': 'text/html' }),
|
||||
json: () => Promise.resolve({}),
|
||||
} as Response);
|
||||
|
||||
await expect(client.query({ entity: 'ITMMASTER' })).rejects.toThrow(
|
||||
'Non-JSON response received (possible login redirect)',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on timeout via AbortSignal', async () => {
|
||||
fetchSpy.mockImplementationOnce(
|
||||
() => Promise.reject(new DOMException('The operation was aborted', 'TimeoutError')),
|
||||
);
|
||||
|
||||
await expect(client.query({ entity: 'ITMMASTER' })).rejects.toThrow('aborted');
|
||||
});
|
||||
|
||||
it('throws on 500 Internal Server Error', async () => {
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}, 500, 'Internal Server Error'));
|
||||
|
||||
await expect(client.read('ITMMASTER', 'A001')).rejects.toThrow('HTTP 500: Internal Server Error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TLS configuration', () => {
|
||||
it('sets NODE_TLS_REJECT_UNAUTHORIZED=0 when rejectUnauthorized is false', async () => {
|
||||
const insecureClient = new RestClient({ ...mockConfig, rejectUnauthorized: false });
|
||||
fetchSpy.mockResolvedValueOnce(jsonResponse({}));
|
||||
|
||||
await insecureClient.healthCheck();
|
||||
|
||||
expect(process.env['NODE_TLS_REJECT_UNAUTHORIZED']).toBe('0');
|
||||
});
|
||||
});
|
||||
});
|
||||
103
src/clients/rest-client.ts
Normal file
103
src/clients/rest-client.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { SageConfig, RestQueryOptions, RestQueryResult, RestDetailResult } from '../types/index.js';
|
||||
|
||||
export class RestClient {
|
||||
private config: SageConfig;
|
||||
|
||||
constructor(config: SageConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
private async get(url: string): Promise<unknown> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Authorization': `Basic ${Buffer.from(`${this.config.user}:${this.config.password}`).toString('base64')}`,
|
||||
};
|
||||
|
||||
if (!this.config.rejectUnauthorized) {
|
||||
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (!contentType.includes('application/json')) {
|
||||
throw new Error('Non-JSON response received (possible login redirect)');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async query(options: RestQueryOptions): Promise<RestQueryResult> {
|
||||
let url: string;
|
||||
|
||||
if (options.nextUrl) {
|
||||
url = options.nextUrl;
|
||||
} else {
|
||||
const repr = options.representation || options.entity;
|
||||
url = `${this.config.url}/api1/x3/erp/${this.config.endpoint}/${options.entity}?representation=${repr}.$query`;
|
||||
|
||||
if (options.where) url += `&where=${encodeURIComponent(options.where)}`;
|
||||
if (options.orderBy) url += `&orderBy=${encodeURIComponent(options.orderBy)}`;
|
||||
if (options.select) url += `&select=${encodeURIComponent(options.select)}`;
|
||||
|
||||
const count = Math.min(options.count || 20, 200);
|
||||
url += `&count=${count}`;
|
||||
}
|
||||
|
||||
const data = await this.get(url) as Record<string, unknown>;
|
||||
const resources = (data?.['$resources'] as unknown[]) || [];
|
||||
const links = data?.['$links'] as Record<string, unknown> | undefined;
|
||||
const next = links?.['$next'] as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
records: resources,
|
||||
pagination: {
|
||||
returned: resources.length,
|
||||
hasMore: !!next,
|
||||
nextUrl: next?.['$url'] as string | undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async read(entity: string, key: string, representation?: string): Promise<RestDetailResult> {
|
||||
const repr = representation || entity;
|
||||
const url = `${this.config.url}/api1/x3/erp/${this.config.endpoint}/${entity}('${key}')?representation=${repr}.$details`;
|
||||
const data = await this.get(url);
|
||||
return { record: data };
|
||||
}
|
||||
|
||||
async listEntities(): Promise<string[]> {
|
||||
const url = `${this.config.url}/api1/x3/erp/${this.config.endpoint}`;
|
||||
const data = await this.get(url) as Record<string, unknown>;
|
||||
const resources = data?.['$resources'] as Array<Record<string, unknown>> | undefined;
|
||||
|
||||
if (Array.isArray(resources)) {
|
||||
return resources
|
||||
.map((r) => (r['$name'] || r['name'] || r['$title']) as string | undefined)
|
||||
.filter((name): name is string => !!name);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<{ status: string; latencyMs: number }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await this.get(`${this.config.url}/api1/x3/erp/${this.config.endpoint}`);
|
||||
return { status: 'connected', latencyMs: Date.now() - start };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: `error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
latencyMs: Date.now() - start,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user