Skip to content

Commit 9a1c97f

Browse files
committed
rest: streaming CSV / JSON export endpoint (M10.21 / C.21)
Add GET /data/:object/export that streams record exports without buffering the full result set in memory. Query params: format=csv|json default csv. json emits a JSON array. fields=a,b,c default: object schema fields, falls back to keys of the first row when schema lookup is unavailable. filter=<json> URL-encoded $filter, same shape as list endpoint. orderby=field:dir comma-separated shorthand or a JSON object. limit=<n> default 10 000, hard cap 50 000. page=<n> driver chunk size, default 500, max 5 000. The handler pages through findData() in chunks of `page` and writes each chunk straight to the response (Content-Disposition: attachment with a dated filename). CSV cells are RFC-4180 escaped via the new formatCsvCell / rowsToCsv helpers (commas, quotes, CR, LF → quoted; embedded quotes doubled). JSON exports stream as a single JSON array. Auth: same enforceAuth gate as the existing CRUD endpoints. Project scoping mirrors the list handler (works under both /api/v1/data and /api/v1/projects/:projectId/data). Tests (4 new, 57 total): - CSV header + RFC-4180 escaping (commas, embedded quotes, newlines). - JSON array emission. - filter JSON validation (400 on malformed input). - 50 k hard cap is honoured when callers ask for more. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6085a0d commit 9a1c97f

2 files changed

Lines changed: 297 additions & 0 deletions

File tree

packages/rest/src/rest-server.ts

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,39 @@ function parseCsvToRows(csv: string, mapping: Record<string, string> = {}): Arra
235235
return out;
236236
}
237237

238+
/**
239+
* Escape a single value into an RFC-4180 CSV cell. Values containing
240+
* commas, quotes, CR, or LF are wrapped in double-quotes with embedded
241+
* quotes doubled. `null` / `undefined` become an empty cell. Objects and
242+
* arrays are serialised as compact JSON so nested data round-trips
243+
* without flattening surprises.
244+
*/
245+
function formatCsvCell(value: any): string {
246+
if (value === null || value === undefined) return '';
247+
let s: string;
248+
if (typeof value === 'string') s = value;
249+
else if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') s = String(value);
250+
else if (value instanceof Date) s = value.toISOString();
251+
else { try { s = JSON.stringify(value); } catch { s = String(value); } }
252+
if (/[",\r\n]/.test(s)) {
253+
return `"${s.replace(/"/g, '""')}"`;
254+
}
255+
return s;
256+
}
257+
258+
/**
259+
* Serialise a list of rows to RFC-4180 CSV text. Caller supplies the
260+
* ordered list of field names; unknown fields produce empty cells.
261+
*/
262+
function rowsToCsv(fields: string[], rows: Array<Record<string, any>>, includeHeader: boolean): string {
263+
const lines: string[] = [];
264+
if (includeHeader) lines.push(fields.map(formatCsvCell).join(','));
265+
for (const row of rows) {
266+
lines.push(fields.map(f => formatCsvCell(row?.[f])).join(','));
267+
}
268+
return lines.join('\r\n') + (lines.length > 0 ? '\r\n' : '');
269+
}
270+
238271
/**
239272
* Structural subset of `KernelManager` that RestServer needs in order to
240273
* resolve a per-project protocol at request time. Typed locally to avoid
@@ -1628,6 +1661,156 @@ export class RestServer {
16281661
tags: ['data', 'import'],
16291662
},
16301663
});
1664+
1665+
// GET /data/:object/export — streaming export (M10.21 / C.21)
1666+
//
1667+
// Query params:
1668+
// format=csv|json (default: csv. json emits a JSON array.)
1669+
// fields=a,b,c (default: derive from object schema; falls back to keys of the first row)
1670+
// filter=<json> ($filter as URL-encoded JSON, same shape as list endpoint)
1671+
// orderby=field:desc (optional ordering, mirrors $orderby semantics)
1672+
// limit=<n> (default 10000, hard cap 50000)
1673+
// page=<n> (driver chunk size, default 500, max 5000)
1674+
//
1675+
// Streams the response so 50k-row exports do not buffer in memory.
1676+
// Filename suggests `${object}-${YYYY-MM-DD}.${ext}` for browsers.
1677+
this.routeManager.register({
1678+
method: 'GET',
1679+
path: `${dataPath}/:object/export`,
1680+
handler: async (req: any, res: any) => {
1681+
try {
1682+
const projectId = isScoped ? req.params?.projectId : undefined;
1683+
const p = await this.resolveProtocol(projectId, req);
1684+
const context = await this.resolveExecCtx(projectId, req);
1685+
if (this.enforceAuth(req, res, context)) return;
1686+
const objectName = String(req.params.object || '');
1687+
if (!objectName) {
1688+
res.status(400).json({ code: 'INVALID_REQUEST', error: 'object is required' });
1689+
return;
1690+
}
1691+
const q = req.query ?? {};
1692+
const format = (String(q.format ?? 'csv')).toLowerCase() === 'json' ? 'json' : 'csv';
1693+
const HARD_CAP = 50_000;
1694+
const MAX_CHUNK = 5_000;
1695+
const requestedLimit = q.limit != null ? Math.max(1, Number(q.limit) || 0) : 10_000;
1696+
const limit = Math.min(requestedLimit, HARD_CAP);
1697+
const chunkSize = Math.min(MAX_CHUNK, Math.max(50, q.page != null ? Number(q.page) || 500 : 500));
1698+
1699+
let filter: any = undefined;
1700+
if (typeof q.filter === 'string' && q.filter.length > 0) {
1701+
try { filter = JSON.parse(q.filter); }
1702+
catch {
1703+
res.status(400).json({ code: 'INVALID_REQUEST', error: 'filter must be JSON' });
1704+
return;
1705+
}
1706+
} else if (q.filter && typeof q.filter === 'object') {
1707+
filter = q.filter;
1708+
}
1709+
1710+
let orderby: any = undefined;
1711+
if (typeof q.orderby === 'string' && q.orderby.length > 0) {
1712+
// Accept "field:dir,field2:dir" shorthand or a JSON object.
1713+
if (q.orderby.startsWith('{') || q.orderby.startsWith('[')) {
1714+
try { orderby = JSON.parse(q.orderby); } catch { /* leave undefined */ }
1715+
} else {
1716+
const obj: Record<string, 'asc' | 'desc'> = {};
1717+
for (const part of q.orderby.split(',')) {
1718+
const [field, dir] = part.split(':').map((s: string) => s.trim());
1719+
if (field) obj[field] = dir?.toLowerCase() === 'desc' ? 'desc' : 'asc';
1720+
}
1721+
if (Object.keys(obj).length > 0) orderby = obj;
1722+
}
1723+
}
1724+
1725+
// Resolve fields: explicit param > schema fields > derived from first row.
1726+
let fields: string[] | undefined;
1727+
if (typeof q.fields === 'string' && q.fields.length > 0) {
1728+
fields = q.fields.split(',').map((s: string) => s.trim()).filter(Boolean);
1729+
} else if (Array.isArray(q.fields)) {
1730+
fields = q.fields.filter((s: any) => typeof s === 'string' && s.length > 0);
1731+
}
1732+
if (!fields || fields.length === 0) {
1733+
try {
1734+
const schema = await (p as any).getObjectSchema?.(objectName, projectId);
1735+
const schemaFields = schema?.fields;
1736+
if (Array.isArray(schemaFields)) {
1737+
fields = schemaFields.map((f: any) => f.name).filter((n: any) => typeof n === 'string');
1738+
}
1739+
} catch { /* fall back to first-row derivation */ }
1740+
}
1741+
1742+
// Prepare streaming response. Set headers BEFORE first write.
1743+
const stamp = new Date().toISOString().slice(0, 10);
1744+
const safeObj = objectName.replace(/[^A-Za-z0-9_.-]/g, '_');
1745+
if (format === 'csv') {
1746+
res.header('Content-Type', 'text/csv; charset=utf-8');
1747+
res.header('Content-Disposition', `attachment; filename="${safeObj}-${stamp}.csv"`);
1748+
} else {
1749+
res.header('Content-Type', 'application/json; charset=utf-8');
1750+
res.header('Content-Disposition', `attachment; filename="${safeObj}-${stamp}.json"`);
1751+
}
1752+
res.header('X-Export-Format', format);
1753+
res.header('X-Export-Limit', String(limit));
1754+
res.header('Cache-Control', 'no-store');
1755+
1756+
let exported = 0;
1757+
let firstChunk = true;
1758+
let skip = 0;
1759+
if (format === 'json') res.write('[');
1760+
1761+
while (exported < limit) {
1762+
const take = Math.min(chunkSize, limit - exported);
1763+
const findArgs: any = {
1764+
object: objectName,
1765+
query: {
1766+
...(filter ? { $filter: filter } : {}),
1767+
...(orderby ? { $orderby: orderby } : {}),
1768+
$top: take,
1769+
$skip: skip,
1770+
},
1771+
...(projectId ? { projectId } : {}),
1772+
...(context ? { context } : {}),
1773+
};
1774+
const result: any = await (p as any).findData(findArgs);
1775+
const rows: any[] = Array.isArray(result?.data) ? result.data
1776+
: Array.isArray(result?.rows) ? result.rows
1777+
: Array.isArray(result) ? result : [];
1778+
1779+
if (rows.length === 0) break;
1780+
1781+
if (format === 'csv') {
1782+
// Derive fields from the first row if schema lookup failed.
1783+
if ((!fields || fields.length === 0) && firstChunk) {
1784+
fields = Object.keys(rows[0] ?? {});
1785+
}
1786+
const text = rowsToCsv(fields ?? [], rows, firstChunk);
1787+
res.write(text);
1788+
} else {
1789+
for (let i = 0; i < rows.length; i++) {
1790+
const prefix = (firstChunk && i === 0) ? '' : ',';
1791+
res.write(prefix + JSON.stringify(rows[i]));
1792+
}
1793+
}
1794+
firstChunk = false;
1795+
exported += rows.length;
1796+
skip += rows.length;
1797+
if (rows.length < take) break;
1798+
}
1799+
if (format === 'json') res.write(']');
1800+
res.end();
1801+
} catch (error: any) {
1802+
logError('[REST] Unhandled error:', error);
1803+
// Best-effort error envelope; if headers already sent the
1804+
// client receives a truncated stream which signals failure.
1805+
try { sendError(res, error, String(req.params?.object || '')); }
1806+
catch { try { res.end(); } catch { /* swallow */ } }
1807+
}
1808+
},
1809+
metadata: {
1810+
summary: 'Streaming export of object rows (CSV or JSON)',
1811+
tags: ['data', 'export'],
1812+
},
1813+
});
16311814
}
16321815

16331816
/**

packages/rest/src/rest.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,120 @@ describe('RestServer', () => {
566566
});
567567
});
568568
});
569+
570+
// -----------------------------------------------------------------------
571+
// GET /data/:object/export — streaming CSV / JSON export (M10.21 / C.21)
572+
// -----------------------------------------------------------------------
573+
describe('export handler', () => {
574+
function getExportRoute(rest: any) {
575+
const routes = rest.getRoutes();
576+
return routes.find(
577+
(r: any) => r.method === 'GET' && r.path === '/api/v1/data/:object/export',
578+
);
579+
}
580+
581+
function makeRes() {
582+
const chunks: string[] = [];
583+
const headers: Record<string, string> = {};
584+
let status = 200;
585+
const res: any = {
586+
write: (s: string) => { chunks.push(s); },
587+
end: vi.fn(),
588+
header: (n: string, v: string) => { headers[n] = v; return res; },
589+
status: (code: number) => { status = code; return res; },
590+
json: vi.fn(),
591+
};
592+
return { res, chunks, headers, getStatus: () => status };
593+
}
594+
595+
it('streams CSV with header row and quotes risky cells', async () => {
596+
const rest = new RestServer(server as any, protocol as any);
597+
rest.registerRoutes();
598+
const route = getExportRoute(rest);
599+
expect(route).toBeDefined();
600+
601+
protocol.findData.mockResolvedValueOnce({
602+
data: [
603+
{ id: '1', name: 'Acme, Inc.', note: 'line1\nline2' },
604+
{ id: '2', name: 'Beta "Co"', note: null },
605+
],
606+
});
607+
// Second call returns empty -> ends the stream
608+
protocol.findData.mockResolvedValueOnce({ data: [] });
609+
610+
const { res, chunks, headers } = makeRes();
611+
const req = {
612+
params: { object: 'account' },
613+
query: { format: 'csv', fields: 'id,name,note', limit: '500' },
614+
};
615+
616+
await route!.handler(req as any, res);
617+
618+
expect(headers['Content-Type']).toBe('text/csv; charset=utf-8');
619+
expect(headers['Content-Disposition']).toMatch(/attachment; filename="account-\d{4}-\d{2}-\d{2}\.csv"/);
620+
const text = chunks.join('');
621+
expect(text.startsWith('id,name,note\r\n')).toBe(true);
622+
expect(text).toContain('1,"Acme, Inc.","line1\nline2"');
623+
expect(text).toContain('2,"Beta ""Co""",');
624+
expect(res.end).toHaveBeenCalled();
625+
});
626+
627+
it('streams JSON array when format=json', async () => {
628+
const rest = new RestServer(server as any, protocol as any);
629+
rest.registerRoutes();
630+
const route = getExportRoute(rest);
631+
632+
protocol.findData.mockResolvedValueOnce({
633+
data: [{ id: 'a' }, { id: 'b' }],
634+
});
635+
protocol.findData.mockResolvedValueOnce({ data: [] });
636+
637+
const { res, chunks, headers } = makeRes();
638+
await route!.handler({
639+
params: { object: 'lead' },
640+
query: { format: 'json' },
641+
} as any, res);
642+
643+
expect(headers['Content-Type']).toBe('application/json; charset=utf-8');
644+
const text = chunks.join('');
645+
expect(text).toBe('[{"id":"a"},{"id":"b"}]');
646+
});
647+
648+
it('rejects invalid JSON in filter query', async () => {
649+
const rest = new RestServer(server as any, protocol as any);
650+
rest.registerRoutes();
651+
const route = getExportRoute(rest);
652+
653+
const { res } = makeRes();
654+
await route!.handler({
655+
params: { object: 'account' },
656+
query: { filter: '{not json' },
657+
} as any, res);
658+
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'INVALID_REQUEST' }));
659+
});
660+
661+
it('honours the hard 50k row cap', async () => {
662+
const rest = new RestServer(server as any, protocol as any);
663+
rest.registerRoutes();
664+
const route = getExportRoute(rest);
665+
666+
// Always return full chunks so the loop is bounded only by `limit`.
667+
protocol.findData.mockImplementation(async ({ query }: any) => {
668+
const take = query?.$top ?? 0;
669+
return { data: Array.from({ length: take }, (_v, i) => ({ id: String((query?.$skip ?? 0) + i) })) };
670+
});
671+
672+
const { res, chunks } = makeRes();
673+
await route!.handler({
674+
params: { object: 'big' },
675+
query: { format: 'csv', limit: '999999', fields: 'id' },
676+
} as any, res);
677+
678+
const lines = chunks.join('').split('\r\n').filter(Boolean);
679+
// 1 header + 50 000 rows.
680+
expect(lines.length).toBe(50_001);
681+
});
682+
});
569683
});
570684

571685
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)