Skip to content

Commit 6953af6

Browse files
committed
fix: parseMultipart
1 parent fb3a2fb commit 6953af6

File tree

5 files changed

+325
-82
lines changed

5 files changed

+325
-82
lines changed

src/services/api/viewer.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import type {TUserToken} from '../../types/api/whoami';
3535
import type {QuerySyntax, TransactionMode} from '../../types/store/query';
3636
import {BINARY_DATA_IN_PLAIN_TEXT_DISPLAY} from '../../utils/constants';
3737
import type {Nullable} from '../../utils/typecheckers';
38-
import {multipartParser} from '../parsers/multipart';
38+
import {parseMultipart, resetMultipartState} from '../parsers/parseMultipart';
3939
import {settingsManager} from '../settings';
4040

4141
import {BaseYdbAPI} from './base';
@@ -389,8 +389,8 @@ export class ViewerAPI extends BaseYdbAPI {
389389
true,
390390
);
391391

392-
// Reset parser state for new request
393-
multipartParser.reset();
392+
// Create parser state in closure
393+
let parserState = resetMultipartState();
394394

395395
return this.get<string>(
396396
this.getPath('/viewer/query'),
@@ -415,10 +415,12 @@ export class ViewerAPI extends BaseYdbAPI {
415415
onDownloadProgress: (progressEvent) => {
416416
console.log('progressEvent', progressEvent.event.target);
417417
const response = progressEvent.event.target as XMLHttpRequest;
418-
multipartParser.processNewData<QueryAPIResponse<Action>>(
419-
response.responseText,
420-
onChunk,
421-
);
418+
const {chunks, state} = parseMultipart<QueryAPIResponse<Action>>({
419+
responseText: response.responseText,
420+
state: parserState,
421+
});
422+
parserState = state;
423+
chunks.forEach((chunk) => onChunk?.(chunk.content));
422424
},
423425
},
424426
);
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import {parseMultipart, resetMultipartState} from '../parseMultipart';
2+
3+
describe('parseMultipart', () => {
4+
class MultipartBuilder {
5+
private chunks: string[] = [];
6+
private readonly boundary: string;
7+
8+
constructor(boundary = 'boundary') {
9+
this.boundary = boundary;
10+
}
11+
12+
addChunk(counter: number, data: Record<string, unknown>) {
13+
const content = JSON.stringify({Counter: counter, ...data});
14+
this.chunks.push(
15+
[`--${this.boundary}`, 'Content-Type: application/json', '', content].join('\n'),
16+
);
17+
return this;
18+
}
19+
20+
addRawChunk(content: string) {
21+
this.chunks.push(`--${this.boundary}${content}`);
22+
return this;
23+
}
24+
25+
// Get partial response without closing boundary
26+
getPartial(upToChunkIndex = -1) {
27+
const chunks =
28+
upToChunkIndex >= 0 ? this.chunks.slice(0, upToChunkIndex + 1) : this.chunks;
29+
return chunks.join('\n');
30+
}
31+
32+
// Get complete response with closing boundary
33+
getComplete() {
34+
return this.chunks.join('\n') + `\n--${this.boundary}--`;
35+
}
36+
}
37+
38+
it('should parse a single complete chunk', () => {
39+
const builder = new MultipartBuilder().addChunk(1, {value: 'test'});
40+
41+
const {chunks, state} = parseMultipart<{Counter: number; value: string}>({
42+
responseText: builder.getComplete(),
43+
state: resetMultipartState(),
44+
});
45+
46+
expect(chunks).toHaveLength(1);
47+
expect(chunks[0]).toEqual({
48+
part_number: 1,
49+
total_parts: 0,
50+
content: {Counter: 1, value: 'test'},
51+
});
52+
expect(state.buffer).toBe('');
53+
});
54+
55+
it('should handle multiple chunks in one response', () => {
56+
const builder = new MultipartBuilder()
57+
.addChunk(1, {value: 'test1'})
58+
.addChunk(2, {value: 'test2'});
59+
60+
const {chunks} = parseMultipart<{Counter: number; value: string}>({
61+
responseText: builder.getComplete(),
62+
state: resetMultipartState(),
63+
});
64+
65+
expect(chunks).toHaveLength(2);
66+
expect(chunks[0].content).toEqual({Counter: 1, value: 'test1'});
67+
expect(chunks[1].content).toEqual({Counter: 2, value: 'test2'});
68+
});
69+
70+
it('should handle partial chunks with state management', () => {
71+
const builder = new MultipartBuilder()
72+
.addChunk(1, {value: 'test1'})
73+
.addRawChunk('\nContent-Type: application/json\n\n{"Counter": 2');
74+
75+
// First part of the response
76+
const firstResult = parseMultipart<{Counter: number; value: string}>({
77+
responseText: builder.getPartial(),
78+
state: resetMultipartState(),
79+
});
80+
81+
expect(firstResult.chunks).toHaveLength(1);
82+
expect(firstResult.chunks[0].content).toEqual({Counter: 1, value: 'test1'});
83+
expect(firstResult.state.buffer).toContain('{"Counter": 2');
84+
85+
// Complete the partial chunk
86+
const fullBuilder = new MultipartBuilder()
87+
.addChunk(1, {value: 'test1'})
88+
.addChunk(2, {value: 'test2'});
89+
90+
const secondResult = parseMultipart<{Counter: number; value: string}>({
91+
responseText: fullBuilder.getComplete(),
92+
state: firstResult.state,
93+
});
94+
95+
expect(secondResult.chunks).toHaveLength(1);
96+
expect(secondResult.chunks[0].content).toEqual({Counter: 2, value: 'test2'});
97+
expect(secondResult.state.buffer).toBe('');
98+
});
99+
100+
it('should handle empty response', () => {
101+
const {chunks, state} = parseMultipart({
102+
responseText: '',
103+
state: resetMultipartState(),
104+
});
105+
106+
expect(chunks).toHaveLength(0);
107+
expect(state).toEqual(resetMultipartState());
108+
});
109+
110+
it('should handle invalid JSON in chunk', () => {
111+
const builder = new MultipartBuilder().addRawChunk(
112+
'\nContent-Type: application/json\n\n{"Counter": 1, invalid json}',
113+
);
114+
115+
const {chunks} = parseMultipart({
116+
responseText: builder.getComplete(),
117+
state: resetMultipartState(),
118+
});
119+
120+
expect(chunks).toHaveLength(0);
121+
});
122+
123+
it('should handle chunks without Counter property', () => {
124+
const builder = new MultipartBuilder().addRawChunk(
125+
'\nContent-Type: application/json\n\n{"value": "test"}',
126+
);
127+
128+
const {chunks} = parseMultipart({
129+
responseText: builder.getComplete(),
130+
state: resetMultipartState(),
131+
});
132+
133+
expect(chunks).toHaveLength(1);
134+
expect(chunks[0].part_number).toBe(0);
135+
});
136+
137+
it('should handle custom boundary', () => {
138+
const customBoundary = 'custom-boundary';
139+
const builder = new MultipartBuilder(customBoundary).addChunk(1, {value: 'test'});
140+
141+
const {chunks} = parseMultipart<{Counter: number; value: string}>({
142+
responseText: builder.getComplete(),
143+
state: resetMultipartState(),
144+
boundary: customBoundary,
145+
});
146+
147+
expect(chunks).toHaveLength(1);
148+
expect(chunks[0].content).toEqual({Counter: 1, value: 'test'});
149+
});
150+
151+
it('should handle multiple partial chunks', () => {
152+
const builder = new MultipartBuilder()
153+
.addChunk(1, {value: 'test1'})
154+
.addChunk(2, {value: 'test2'})
155+
.addRawChunk('\nContent-Type: application/json\n\n{"Counter": 3');
156+
157+
// First response with two complete chunks and one partial
158+
const firstResult = parseMultipart<{Counter: number; value: string}>({
159+
responseText: builder.getPartial(),
160+
state: resetMultipartState(),
161+
});
162+
163+
expect(firstResult.chunks).toHaveLength(2);
164+
expect(firstResult.state.buffer).toContain('{"Counter": 3');
165+
166+
// Complete the response
167+
const fullBuilder = new MultipartBuilder()
168+
.addChunk(1, {value: 'test1'})
169+
.addChunk(2, {value: 'test2'})
170+
.addChunk(3, {value: 'test3'});
171+
172+
const secondResult = parseMultipart<{Counter: number; value: string}>({
173+
responseText: fullBuilder.getComplete(),
174+
state: firstResult.state,
175+
});
176+
177+
expect(secondResult.chunks).toHaveLength(1);
178+
expect(secondResult.chunks[0].content).toEqual({Counter: 3, value: 'test3'});
179+
});
180+
});

src/services/parsers/multipart.ts

Lines changed: 0 additions & 74 deletions
This file was deleted.

0 commit comments

Comments
 (0)