Skip to content

Commit 29712af

Browse files
wraithgarshmam
authored andcommittedJun 12, 2024··
feat: merging functionality from minipass-json-stream
1 parent 9a3e7e8 commit 29712af

File tree

5 files changed

+519
-47
lines changed

5 files changed

+519
-47
lines changed
 

‎lib/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ const { HttpErrorAuthOTP } = require('./errors.js')
44
const checkResponse = require('./check-response.js')
55
const getAuth = require('./auth.js')
66
const fetch = require('make-fetch-happen')
7-
const JSONStream = require('minipass-json-stream')
7+
const JSONStream = require('./json-stream')
88
const npa = require('npm-package-arg')
99
const qs = require('querystring')
1010
const url = require('url')

‎lib/json-stream.js

+223
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
const Parser = require('jsonparse')
2+
const { Minipass } = require('minipass')
3+
4+
class JSONStreamError extends Error {
5+
constructor (err, caller) {
6+
super(err.message)
7+
Error.captureStackTrace(this, caller || this.constructor)
8+
}
9+
10+
get name () {
11+
return 'JSONStreamError'
12+
}
13+
}
14+
15+
const check = (x, y) =>
16+
typeof x === 'string' ? String(y) === x
17+
: x && typeof x.test === 'function' ? x.test(y)
18+
: typeof x === 'boolean' || typeof x === 'object' ? x
19+
: typeof x === 'function' ? x(y)
20+
: false
21+
22+
class JSONStream extends Minipass {
23+
#count = 0
24+
#ending = false
25+
#footer = null
26+
#header = null
27+
#map = null
28+
#onTokenOriginal
29+
#parser
30+
#path = null
31+
#root = null
32+
33+
constructor (opts) {
34+
super({
35+
...opts,
36+
objectMode: true,
37+
})
38+
39+
const parser = this.#parser = new Parser()
40+
parser.onValue = value => this.#onValue(value)
41+
this.#onTokenOriginal = parser.onToken
42+
parser.onToken = (token, value) => this.#onToken(token, value)
43+
parser.onError = er => this.#onError(er)
44+
45+
this.#path = typeof opts.path === 'string'
46+
? opts.path.split('.').map(e =>
47+
e === '$*' ? { emitKey: true }
48+
: e === '*' ? true
49+
: e === '' ? { recurse: true }
50+
: e)
51+
: Array.isArray(opts.path) && opts.path.length ? opts.path
52+
: null
53+
54+
if (typeof opts.map === 'function') {
55+
this.#map = opts.map
56+
}
57+
}
58+
59+
#setHeaderFooter (key, value) {
60+
// header has not been emitted yet
61+
if (this.#header !== false) {
62+
this.#header = this.#header || {}
63+
this.#header[key] = value
64+
}
65+
66+
// footer has not been emitted yet but header has
67+
if (this.#footer !== false && this.#header === false) {
68+
this.#footer = this.#footer || {}
69+
this.#footer[key] = value
70+
}
71+
}
72+
73+
#onError (er) {
74+
// error will always happen during a write() call.
75+
const caller = this.#ending ? this.end : this.write
76+
this.#ending = false
77+
return this.emit('error', new JSONStreamError(er, caller))
78+
}
79+
80+
#onToken (token, value) {
81+
const parser = this.#parser
82+
this.#onTokenOriginal.call(this.#parser, token, value)
83+
if (parser.stack.length === 0) {
84+
if (this.#root) {
85+
const root = this.#root
86+
if (!this.#path) {
87+
super.write(root)
88+
}
89+
this.#root = null
90+
this.#count = 0
91+
}
92+
}
93+
}
94+
95+
#onValue (value) {
96+
const parser = this.#parser
97+
// the LAST onValue encountered is the root object.
98+
// just overwrite it each time.
99+
this.#root = value
100+
101+
if (!this.#path) {
102+
return
103+
}
104+
105+
let i = 0 // iterates on path
106+
let j = 0 // iterates on stack
107+
let emitKey = false
108+
while (i < this.#path.length) {
109+
const key = this.#path[i]
110+
j++
111+
112+
if (key && !key.recurse) {
113+
const c = (j === parser.stack.length) ? parser : parser.stack[j]
114+
if (!c) {
115+
return
116+
}
117+
if (!check(key, c.key)) {
118+
this.#setHeaderFooter(c.key, value)
119+
return
120+
}
121+
emitKey = !!key.emitKey
122+
i++
123+
} else {
124+
i++
125+
if (i >= this.#path.length) {
126+
return
127+
}
128+
const nextKey = this.#path[i]
129+
if (!nextKey) {
130+
return
131+
}
132+
while (true) {
133+
const c = (j === parser.stack.length) ? parser : parser.stack[j]
134+
if (!c) {
135+
return
136+
}
137+
if (check(nextKey, c.key)) {
138+
i++
139+
if (!Object.isFrozen(parser.stack[j])) {
140+
parser.stack[j].value = null
141+
}
142+
break
143+
} else {
144+
this.#setHeaderFooter(c.key, value)
145+
}
146+
j++
147+
}
148+
}
149+
}
150+
151+
// emit header
152+
if (this.#header) {
153+
const header = this.#header
154+
this.#header = false
155+
this.emit('header', header)
156+
}
157+
if (j !== parser.stack.length) {
158+
return
159+
}
160+
161+
this.#count++
162+
const actualPath = parser.stack.slice(1)
163+
.map(e => e.key).concat([parser.key])
164+
if (value !== null && value !== undefined) {
165+
const data = this.#map ? this.#map(value, actualPath) : value
166+
if (data !== null && data !== undefined) {
167+
const emit = emitKey ? { value: data } : data
168+
if (emitKey) {
169+
emit.key = parser.key
170+
}
171+
super.write(emit)
172+
}
173+
}
174+
175+
if (parser.value) {
176+
delete parser.value[parser.key]
177+
}
178+
179+
for (const k of parser.stack) {
180+
k.value = null
181+
}
182+
}
183+
184+
write (chunk, encoding) {
185+
if (typeof chunk === 'string') {
186+
chunk = Buffer.from(chunk, encoding)
187+
} else if (!Buffer.isBuffer(chunk)) {
188+
return this.emit('error', new TypeError(
189+
'Can only parse JSON from string or buffer input'))
190+
}
191+
this.#parser.write(chunk)
192+
return this.flowing
193+
}
194+
195+
end (chunk, encoding) {
196+
this.#ending = true
197+
if (chunk) {
198+
this.write(chunk, encoding)
199+
}
200+
201+
const h = this.#header
202+
this.#header = null
203+
const f = this.#footer
204+
this.#footer = null
205+
if (h) {
206+
this.emit('header', h)
207+
}
208+
if (f) {
209+
this.emit('footer', f)
210+
}
211+
return super.end()
212+
}
213+
214+
static get JSONStreamError () {
215+
return JSONStreamError
216+
}
217+
218+
static parse (path, map) {
219+
return new JSONStream({ path, map })
220+
}
221+
}
222+
223+
module.exports = JSONStream

‎test/index.js

+1-46
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
'use strict'
1+
const t = require('tap')
22

33
const { Minipass } = require('minipass')
44
const ssri = require('ssri')
5-
const t = require('tap')
65
const zlib = require('zlib')
76
const defaultOpts = require('../lib/default-opts.js')
87
const tnock = require('./util/tnock.js')
@@ -273,50 +272,6 @@ t.test('query string with ?write=true', t => {
273272
.then(res => t.strictSame(res, { write: 'go for it' }))
274273
})
275274

276-
t.test('fetch.json.stream()', async t => {
277-
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
278-
a: 1,
279-
b: 2,
280-
c: 3,
281-
})
282-
const data = await fetch.json.stream('/hello', '$*', OPTS).collect()
283-
t.same(data, [
284-
{ key: 'a', value: 1 },
285-
{ key: 'b', value: 2 },
286-
{ key: 'c', value: 3 },
287-
], 'got a streamed JSON body')
288-
})
289-
290-
t.test('fetch.json.stream opts.mapJSON', async t => {
291-
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
292-
a: 1,
293-
b: 2,
294-
c: 3,
295-
})
296-
const data = await fetch.json.stream('/hello', '*', {
297-
...OPTS,
298-
mapJSON (value, [key]) {
299-
return [key, value]
300-
},
301-
}).collect()
302-
t.same(data, [
303-
['a', 1],
304-
['b', 2],
305-
['c', 3],
306-
], 'data mapped')
307-
})
308-
309-
t.test('fetch.json.stream gets fetch error on stream', async t => {
310-
await t.rejects(fetch.json.stream('/hello', '*', {
311-
...OPTS,
312-
body: Promise.reject(new Error('no body for you')),
313-
method: 'POST',
314-
gzip: true, // make sure we don't gzip the promise, lol!
315-
}).collect(), {
316-
message: 'no body for you',
317-
})
318-
})
319-
320275
t.test('opts.ignoreBody', async t => {
321276
tnock(t, defaultOpts.registry)
322277
.get('/hello')

‎test/json-stream.js

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
const t = require('tap')
2+
const { JSONStreamError, parse } = require('../lib/json-stream.js')
3+
4+
t.test('JSONStream', (t) => {
5+
t.test('JSONStreamError constructor', (t) => {
6+
const error = new JSONStreamError(new Error('error'))
7+
t.equal(error.message, 'error')
8+
t.equal(error.name, 'JSONStreamError')
9+
t.end()
10+
})
11+
12+
t.test('JSONStream.write', (t) => {
13+
t.test('JSONStream write error from numerical (not string not buffer)', async (t) => {
14+
const stream = parse('*', {})
15+
try {
16+
stream.write(5)
17+
} catch (error) {
18+
t.equal(error.message, 'Can only parse JSON from string or buffer input')
19+
t.equal(error.name, 'TypeError')
20+
}
21+
t.end()
22+
})
23+
24+
t.end()
25+
})
26+
27+
t.test('JSONStream.end', (t) => {
28+
t.test(
29+
'JSONStream end invalid chunk throws JSONStreamError from parser',
30+
(t) => {
31+
const stream = parse('*', {})
32+
try {
33+
stream.end('not a valid chunk')
34+
} catch (error) {
35+
t.equal(error.name, 'JSONStreamError')
36+
t.equal(error.message, 'Unexpected "o" at position 1 in state STOP')
37+
}
38+
t.end()
39+
}
40+
)
41+
42+
t.end()
43+
})
44+
45+
t.end()
46+
})

‎test/stream.js

+248
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
const t = require('tap')
2+
3+
const tnock = require('./util/tnock.js')
4+
const defaultOpts = require('../lib/default-opts.js')
5+
defaultOpts.registry = 'https://mock.reg/'
6+
7+
const fetch = require('..')
8+
9+
const OPTS = {
10+
timeout: 0,
11+
retry: {
12+
retries: 1,
13+
factor: 1,
14+
minTimeout: 1,
15+
maxTimeout: 10,
16+
},
17+
}
18+
19+
t.test('json.stream', (t) => {
20+
t.test('fetch.json.stream()', async (t) => {
21+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
22+
a: 1,
23+
b: 2,
24+
c: 3,
25+
something: null,
26+
})
27+
const data = await fetch.json.stream('/hello', '$*', OPTS).collect()
28+
t.same(
29+
data,
30+
[
31+
{ key: 'a', value: 1 },
32+
{ key: 'b', value: 2 },
33+
{ key: 'c', value: 3 },
34+
],
35+
'got a streamed JSON body'
36+
)
37+
})
38+
39+
t.test('fetch.json.stream opts.mapJSON', async (t) => {
40+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
41+
a: 1,
42+
b: 2,
43+
c: 3,
44+
})
45+
const data = await fetch.json
46+
.stream('/hello', '*', {
47+
...OPTS,
48+
mapJSON (value, [key]) {
49+
return [key, value]
50+
},
51+
})
52+
.collect()
53+
t.same(
54+
data,
55+
[
56+
['a', 1],
57+
['b', 2],
58+
['c', 3],
59+
],
60+
'data mapped'
61+
)
62+
})
63+
64+
t.test('fetch.json.stream opts.mapJSON that returns null', async (t) => {
65+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
66+
a: 1,
67+
b: 2,
68+
c: 3,
69+
})
70+
const data = await fetch.json
71+
.stream('/hello', '*', {
72+
...OPTS,
73+
// eslint-disable-next-line no-unused-vars
74+
mapJSON (_value, [_key]) {
75+
return null
76+
},
77+
})
78+
.collect()
79+
t.same(data, [])
80+
})
81+
82+
t.test('fetch.json.stream gets fetch error on stream', async (t) => {
83+
await t.rejects(
84+
fetch.json
85+
.stream('/hello', '*', {
86+
...OPTS,
87+
body: Promise.reject(new Error('no body for you')),
88+
method: 'POST',
89+
gzip: true, // make sure we don't gzip the promise, lol!
90+
})
91+
.collect(),
92+
{
93+
message: 'no body for you',
94+
}
95+
)
96+
})
97+
98+
t.test('fetch.json.stream() sets header and footer', async (t) => {
99+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
100+
a: 1,
101+
b: 2,
102+
c: 3,
103+
})
104+
const data = await fetch.json
105+
.stream('/hello', 'something-random', OPTS)
106+
.collect()
107+
t.same(data, [], 'no data')
108+
})
109+
110+
t.test('fetch.json.stream() with recursive JSON', async (t) => {
111+
tnock(t, defaultOpts.registry)
112+
.get('/hello')
113+
.reply(200, {
114+
dogs: [
115+
{
116+
name: 'george',
117+
owner: {
118+
name: 'bob',
119+
},
120+
},
121+
{
122+
name: 'fred',
123+
owner: {
124+
name: 'alice',
125+
},
126+
},
127+
{
128+
name: 'jill',
129+
owner: {
130+
name: 'fred',
131+
},
132+
},
133+
],
134+
})
135+
136+
const data = await fetch.json
137+
.stream('/hello', 'dogs..name', OPTS)
138+
.collect()
139+
t.same(data, ['george', 'bob', 'fred', 'alice', 'jill', 'fred'])
140+
})
141+
142+
t.test('fetch.json.stream() with undefined path', async (t) => {
143+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
144+
a: 1,
145+
})
146+
const data = await fetch.json.stream('/hello', undefined, OPTS).collect()
147+
t.same(data, [{ a: 1 }])
148+
})
149+
150+
t.test('fetch.json.stream() with empty path', async (t) => {
151+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
152+
a: 1,
153+
})
154+
const data = await fetch.json.stream('/hello', '', OPTS).collect()
155+
t.same(data, [])
156+
})
157+
158+
t.test('fetch.json.stream() with path with function', async (t) => {
159+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
160+
a: {
161+
b: {
162+
c: 1,
163+
},
164+
d: 2,
165+
},
166+
})
167+
const data = await fetch.json
168+
.stream('/hello', [
169+
(a) => a,
170+
{
171+
test: (a) => a,
172+
},
173+
])
174+
.collect()
175+
t.same(data, [{ c: 1 }, 2])
176+
})
177+
178+
t.test('fetch.json.stream() with path array with number in path', async (t) => {
179+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
180+
a: 1,
181+
})
182+
const data = await fetch.json.stream('/hello', [1], OPTS).collect()
183+
t.same(data, [])
184+
})
185+
186+
t.test(
187+
'fetch.json.stream() with path array with recursive and undefined value',
188+
async (t) => {
189+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
190+
a: {
191+
b: {
192+
c: 1,
193+
},
194+
d: 2,
195+
},
196+
})
197+
const data = await fetch.json
198+
.stream('/hello', ['a', '', undefined], OPTS)
199+
.collect()
200+
t.same(data, [])
201+
}
202+
)
203+
204+
t.test('fetch.json.stream() emitKey in path', async (t) => {
205+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
206+
a: {
207+
b: 1,
208+
},
209+
})
210+
const data = await fetch.json.stream('/hello', ['a', { emitKey: true }], OPTS).collect()
211+
t.same(data, [{ key: 'b', value: 1 }])
212+
})
213+
214+
t.test('fetch.json.stream with recursive path followed by valid key', async (t) => {
215+
tnock(t, defaultOpts.registry).get('/hello').reply(200, {
216+
a: {
217+
b: 1,
218+
},
219+
})
220+
const data = await fetch.json.stream('/hello', ['', 'a'], OPTS).collect()
221+
t.same(data, [{ b: 1 }])
222+
})
223+
224+
t.test('fetch.json.stream encounters malformed json', async (t) => {
225+
tnock(t, defaultOpts.registry).get('/hello').reply(200, '{')
226+
const data = await fetch.json.stream('/hello', '*', OPTS).collect()
227+
228+
t.same(data, [])
229+
})
230+
231+
t.test('fetch.json.stream encounters not json string data', async (t) => {
232+
tnock(t, defaultOpts.registry).get('/hello').reply(200, 'not json')
233+
234+
// catch rejected promise
235+
t.rejects(fetch.json.stream('/hello', '*', OPTS).collect(), {
236+
message: 'Unexpected "o" at position 1 in state STOP',
237+
})
238+
})
239+
240+
t.test('fetch.json.stream encounters not json numerical data', async (t) => {
241+
tnock(t, defaultOpts.registry).get('/hello').reply(200, 555)
242+
243+
const data = await fetch.json.stream('/hello', '*', OPTS).collect()
244+
t.same(data, [])
245+
})
246+
247+
t.end()
248+
})

0 commit comments

Comments
 (0)
Please sign in to comment.