Skip to content

Commit 99b1195

Browse files
committed
feat(rpc): handle SA returning file
1 parent 6c81fa3 commit 99b1195

6 files changed

Lines changed: 301 additions & 8 deletions

File tree

packages/datasource-rpc/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"build:watch": "tsc --watch",
3333
"clean": "rm -rf coverage dist",
3434
"lint": "eslint src",
35-
"publish:package": "semantic-release"
35+
"publish:package": "semantic-release",
36+
"test": "jest"
3637
}
3738
}

packages/datasource-rpc/src/collection.ts

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ActionResult,
23
Aggregation,
34
BaseCollection,
45
Caller,
@@ -12,6 +13,8 @@ import {
1213
Projection,
1314
RecordData,
1415
} from '@forestadmin/datasource-toolkit';
16+
import { IncomingHttpHeaders } from 'http';
17+
import { Readable } from 'stream';
1518
import superagent from 'superagent';
1619

1720
import { RpcDataSourceOptions } from './types';
@@ -115,7 +118,12 @@ export default class RpcCollection extends BaseCollection {
115118
return response.body;
116119
}
117120

118-
override async execute(caller: Caller, name: string, formValues: RecordData, filter?: Filter) {
121+
override async execute(
122+
caller: Caller,
123+
name: string,
124+
formValues: RecordData,
125+
filter?: Filter,
126+
): Promise<ActionResult> {
119127
const url = `${this.rpcCollectionUri}/action-execute`;
120128

121129
this.logger(
@@ -125,17 +133,47 @@ export default class RpcCollection extends BaseCollection {
125133

126134
const request = superagent.post(url);
127135
appendHeaders(request, this.options.authSecret, caller);
136+
// Buffer the response ourselves to branch between binary (File) and JSON paths on the
137+
// response headers — superagent's default JSON parser would error on binary bodies.
138+
request.buffer(true);
139+
request.parse((res, callback) => {
140+
const chunks: Uint8Array[] = [];
141+
res.on('data', (chunk: Uint8Array) => chunks.push(chunk));
142+
res.once('end', () =>
143+
callback(null, { headers: res.headers, buffer: Buffer.concat(chunks) }),
144+
);
145+
res.once('error', err => callback(err, null));
146+
});
147+
128148
const response = await request.send({
129149
action: name,
130150
filter: keysToSnake(filter),
131151
data: formValues,
132152
});
133-
134-
const body = keysToCamel(response.body);
153+
const { headers, buffer } = response.body as {
154+
headers: IncomingHttpHeaders;
155+
buffer: Buffer;
156+
};
157+
158+
if (headers['x-forest-action-type'] === 'File') {
159+
const responseHeadersHeader = headers['x-forest-action-response-headers'] as
160+
| string
161+
| undefined;
162+
const fileNameHeader = (headers['x-forest-action-file-name'] as string) || '';
163+
164+
return {
165+
type: 'File',
166+
mimeType: headers['content-type'] as string,
167+
name: decodeURIComponent(fileNameHeader),
168+
stream: Readable.from(buffer),
169+
...(responseHeadersHeader ? { responseHeaders: JSON.parse(responseHeadersHeader) } : {}),
170+
};
171+
}
172+
173+
const raw = buffer.toString('utf-8');
174+
const body = keysToCamel(raw ? JSON.parse(raw) : {});
135175
body.invalidated = new Set(body.invalidated);
136176

137-
// TODO action with file
138-
139177
return body;
140178
}
141179

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import http, { IncomingMessage, Server, ServerResponse } from 'http';
2+
import { AddressInfo } from 'net';
3+
import { Readable } from 'stream';
4+
5+
import RpcCollection from '../src/collection';
6+
7+
type Handler = (req: IncomingMessage, res: ServerResponse) => void;
8+
9+
async function startServer(handler: Handler): Promise<{ server: Server; uri: string }> {
10+
const server = http.createServer(handler);
11+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
12+
const { address, port } = server.address() as AddressInfo;
13+
14+
return { server, uri: `http://${address}:${port}` };
15+
}
16+
17+
async function stopServer(server: Server) {
18+
await new Promise<void>(resolve => server.close(() => resolve()));
19+
}
20+
21+
function readBody(req: IncomingMessage): Promise<string> {
22+
return new Promise((resolve, reject) => {
23+
const chunks: Uint8Array[] = [];
24+
req.on('data', chunk => chunks.push(chunk));
25+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
26+
req.on('error', reject);
27+
});
28+
}
29+
30+
function buildCollection(uri: string) {
31+
const logger = jest.fn();
32+
const datasource = { collections: [], schema: { charts: [] } };
33+
const options = { uri, authSecret: 'secret' };
34+
const schema = {
35+
actions: {},
36+
charts: [],
37+
fields: {},
38+
segments: [],
39+
aggregationCapabilities: { supportedDateOperations: new Set(), supportGroups: false },
40+
countable: false,
41+
searchable: false,
42+
};
43+
44+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
45+
return new RpcCollection(logger as any, datasource as any, options, 'books', schema as any);
46+
}
47+
48+
describe('RpcCollection.execute', () => {
49+
let server: Server;
50+
let uri: string;
51+
52+
afterEach(async () => {
53+
if (server) await stopServer(server);
54+
});
55+
56+
it('parses a JSON Success response and rebuilds invalidated as a Set', async () => {
57+
const received: { body?: unknown } = {};
58+
59+
({ server, uri } = await startServer(async (req, res) => {
60+
received.body = JSON.parse(await readBody(req));
61+
res.setHeader('Content-Type', 'application/json');
62+
res.end(
63+
JSON.stringify({
64+
type: 'Success',
65+
message: 'ok',
66+
invalidated: ['books'],
67+
response_headers: { 'x-foo': 'bar' },
68+
}),
69+
);
70+
}));
71+
72+
const collection = buildCollection(uri);
73+
const result = await collection.execute({ id: 1 } as never, 'noop', { foo: 'bar' }, undefined);
74+
75+
expect(received.body).toEqual({ action: 'noop', filter: undefined, data: { foo: 'bar' } });
76+
expect(result).toEqual({
77+
type: 'Success',
78+
message: 'ok',
79+
invalidated: new Set(['books']),
80+
responseHeaders: { 'x-foo': 'bar' },
81+
});
82+
});
83+
84+
it('returns a FileResult with a Readable stream when X-Forest-Action-Type=File', async () => {
85+
({ server, uri } = await startServer(async (req, res) => {
86+
await readBody(req);
87+
res.setHeader('Content-Type', 'application/pdf');
88+
res.setHeader('Content-Disposition', 'attachment; filename="report%20final.pdf"');
89+
res.setHeader('X-Forest-Action-Type', 'File');
90+
res.setHeader('X-Forest-Action-File-Name', 'report%20final.pdf');
91+
res.setHeader(
92+
'X-Forest-Action-Response-Headers',
93+
JSON.stringify({ 'set-cookie': 'token=xyz' }),
94+
);
95+
Readable.from([Buffer.from('hello-pdf')]).pipe(res);
96+
}));
97+
98+
const collection = buildCollection(uri);
99+
const result = await collection.execute({ id: 1 } as never, 'download', {}, undefined);
100+
101+
expect(result.type).toBe('File');
102+
if (result.type !== 'File') throw new Error('unreachable');
103+
expect(result.mimeType).toBe('application/pdf');
104+
expect(result.name).toBe('report final.pdf');
105+
expect(result.responseHeaders).toEqual({ 'set-cookie': 'token=xyz' });
106+
107+
const chunks: Uint8Array[] = [];
108+
for await (const chunk of result.stream) chunks.push(chunk as Uint8Array);
109+
expect(Buffer.concat(chunks).toString('utf-8')).toBe('hello-pdf');
110+
});
111+
112+
it('omits responseHeaders when the header is absent', async () => {
113+
({ server, uri } = await startServer(async (req, res) => {
114+
await readBody(req);
115+
res.setHeader('Content-Type', 'text/plain');
116+
res.setHeader('X-Forest-Action-Type', 'File');
117+
res.setHeader('X-Forest-Action-File-Name', 'note.txt');
118+
res.end('hi');
119+
}));
120+
121+
const collection = buildCollection(uri);
122+
const result = await collection.execute({ id: 1 } as never, 'download', {}, undefined);
123+
124+
expect(result.type).toBe('File');
125+
if (result.type !== 'File') throw new Error('unreachable');
126+
expect(result.responseHeaders).toBeUndefined();
127+
expect(result.name).toBe('note.txt');
128+
});
129+
});

packages/rpc-agent/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"build:watch": "tsc --watch",
2727
"clean": "rm -rf coverage dist",
2828
"lint": "eslint src",
29-
"publish:package": "semantic-release"
29+
"publish:package": "semantic-release",
30+
"test": "jest"
3031
}
3132
}

packages/rpc-agent/src/routes/action.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,25 @@ export default class RpcActionRoute extends CollectionRoute {
1919
parseFilter(this.collection, filter),
2020
);
2121

22-
// TODO action with file
22+
if (actionResult.type === 'File') {
23+
const encodedName = encodeURIComponent(actionResult.name);
24+
25+
context.set('Content-Type', actionResult.mimeType);
26+
context.set('Content-Disposition', `attachment; filename="${encodedName}"`);
27+
context.set('X-Forest-Action-Type', 'File');
28+
context.set('X-Forest-Action-File-Name', encodedName);
29+
30+
if (actionResult.responseHeaders) {
31+
context.set(
32+
'X-Forest-Action-Response-Headers',
33+
JSON.stringify(actionResult.responseHeaders),
34+
);
35+
}
36+
37+
context.body = actionResult.stream;
38+
39+
return;
40+
}
2341

2442
context.response.body = {
2543
...keysToSnake(actionResult),
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { Readable } from 'stream';
2+
3+
import RpcActionRoute from '../../src/routes/action';
4+
5+
type FakeContext = {
6+
request: { body: unknown; headers: Record<string, string> };
7+
headers: Record<string, string>;
8+
response: { body?: unknown };
9+
body?: unknown;
10+
set: jest.Mock;
11+
};
12+
13+
function createContext(body: unknown): FakeContext {
14+
return {
15+
request: { body, headers: {} },
16+
headers: {},
17+
response: {},
18+
set: jest.fn(),
19+
};
20+
}
21+
22+
function createRoute(execute: jest.Mock) {
23+
const route = Object.create(RpcActionRoute.prototype) as RpcActionRoute & {
24+
collection: { execute: jest.Mock };
25+
};
26+
27+
Object.defineProperty(route, 'collection', {
28+
value: { execute },
29+
configurable: true,
30+
});
31+
32+
return route;
33+
}
34+
35+
describe('RpcActionRoute', () => {
36+
describe('handleExecute', () => {
37+
it('serialises a Success result as JSON with invalidated as array', async () => {
38+
const execute = jest.fn().mockResolvedValue({
39+
type: 'Success',
40+
message: 'done',
41+
invalidated: new Set(['books', 'authors']),
42+
responseHeaders: { 'x-foo': 'bar' },
43+
});
44+
const route = createRoute(execute);
45+
const ctx = createContext({ action: 'noop', filter: null, data: { id: 1 } });
46+
47+
await route.handleExecute(ctx);
48+
49+
expect(execute).toHaveBeenCalledTimes(1);
50+
expect(ctx.set).not.toHaveBeenCalled();
51+
expect(ctx.response.body).toEqual({
52+
type: 'Success',
53+
message: 'done',
54+
response_headers: { 'x-foo': 'bar' },
55+
invalidated: ['books', 'authors'],
56+
});
57+
});
58+
59+
it('streams a File result with the expected headers', async () => {
60+
const stream = Readable.from(['hello']);
61+
const execute = jest.fn().mockResolvedValue({
62+
type: 'File',
63+
mimeType: 'application/pdf',
64+
name: 'report final.pdf',
65+
stream,
66+
});
67+
const route = createRoute(execute);
68+
const ctx = createContext({ action: 'download', filter: null, data: {} });
69+
70+
await route.handleExecute(ctx);
71+
72+
expect(ctx.set).toHaveBeenCalledWith('Content-Type', 'application/pdf');
73+
expect(ctx.set).toHaveBeenCalledWith(
74+
'Content-Disposition',
75+
'attachment; filename="report%20final.pdf"',
76+
);
77+
expect(ctx.set).toHaveBeenCalledWith('X-Forest-Action-Type', 'File');
78+
expect(ctx.set).toHaveBeenCalledWith('X-Forest-Action-File-Name', 'report%20final.pdf');
79+
expect(ctx.set).not.toHaveBeenCalledWith(
80+
'X-Forest-Action-Response-Headers',
81+
expect.anything(),
82+
);
83+
expect(ctx.body).toBe(stream);
84+
expect(ctx.response.body).toBeUndefined();
85+
});
86+
87+
it('forwards responseHeaders on File result via X-Forest-Action-Response-Headers', async () => {
88+
const execute = jest.fn().mockResolvedValue({
89+
type: 'File',
90+
mimeType: 'text/csv',
91+
name: 'export.csv',
92+
stream: Readable.from(['a,b']),
93+
responseHeaders: { 'set-cookie': 'token=xyz' },
94+
});
95+
const route = createRoute(execute);
96+
const ctx = createContext({ action: 'export', filter: null, data: {} });
97+
98+
await route.handleExecute(ctx);
99+
100+
expect(ctx.set).toHaveBeenCalledWith(
101+
'X-Forest-Action-Response-Headers',
102+
JSON.stringify({ 'set-cookie': 'token=xyz' }),
103+
);
104+
});
105+
});
106+
});

0 commit comments

Comments
 (0)