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:
2026-03-10 17:02:10 +00:00
parent a7ae8148a3
commit b87f1e327c
2 changed files with 370 additions and 0 deletions

View 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
View 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,
};
}
}
}