Skip to content

Commit 0d8877a

Browse files
authored
feat: add Response.json (nodejs#1452)
* feat: add `Response.json` * fix: serialize value properly * fix: types & more coverage
1 parent 9ffe90d commit 0d8877a

File tree

6 files changed

+248
-61
lines changed

6 files changed

+248
-61
lines changed

lib/fetch/response.js

Lines changed: 106 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const { AbortError } = require('../core/errors')
55
const { extractBody, cloneBody, mixinBody } = require('./body')
66
const util = require('../core/util')
77
const { kEnumerableProperty } = util
8-
const { responseURL, isValidReasonPhrase, toUSVString, isCancelled, isAborted } = require('./util')
8+
const { responseURL, isValidReasonPhrase, toUSVString, isCancelled, isAborted, serializeJavascriptValueToJSONString } = require('./util')
99
const {
1010
redirectStatus,
1111
nullBodyStatus,
@@ -35,6 +35,50 @@ class Response {
3535
return responseObject
3636
}
3737

38+
// https://fetch.spec.whatwg.org/#dom-response-json
39+
static json (data, init = {}) {
40+
if (arguments.length === 0) {
41+
throw new TypeError(
42+
'Failed to execute \'json\' on \'Response\': 1 argument required, but 0 present.'
43+
)
44+
}
45+
46+
if (init === null || typeof init !== 'object') {
47+
throw new TypeError(
48+
`Failed to execute 'json' on 'Response': init must be a RequestInit, found ${typeof init}.`
49+
)
50+
}
51+
52+
init = {
53+
status: 200,
54+
statusText: '',
55+
headers: new HeadersList(),
56+
...init
57+
}
58+
59+
// 1. Let bytes the result of running serialize a JavaScript value to JSON bytes on data.
60+
const bytes = new TextEncoder('utf-8').encode(
61+
serializeJavascriptValueToJSONString(data)
62+
)
63+
64+
// 2. Let body be the result of extracting bytes.
65+
const body = extractBody(bytes)
66+
67+
// 3. Let responseObject be the result of creating a Response object, given a new response,
68+
// "response", and this’s relevant Realm.
69+
const relevantRealm = { settingsObject: {} }
70+
const responseObject = new Response()
71+
responseObject[kRealm] = relevantRealm
72+
responseObject[kHeaders][kGuard] = 'response'
73+
responseObject[kHeaders][kRealm] = relevantRealm
74+
75+
// 4. Perform initialize a response given responseObject, init, and (body, "application/json").
76+
initializeResponse(responseObject, init, { body: body[0], type: 'application/json' })
77+
78+
// 5. Return responseObject.
79+
return responseObject
80+
}
81+
3882
// Creates a redirect Response that redirects to url with status status.
3983
static redirect (...args) {
4084
const relevantRealm = { settingsObject: {} }
@@ -105,76 +149,28 @@ class Response {
105149
// TODO
106150
this[kRealm] = { settingsObject: {} }
107151

108-
// 1. If init["status"] is not in the range 200 to 599, inclusive, then
109-
// throw a RangeError.
110-
if ('status' in init && init.status !== undefined) {
111-
if (!Number.isFinite(init.status)) {
112-
throw new TypeError()
113-
}
114-
115-
if (init.status < 200 || init.status > 599) {
116-
throw new RangeError(
117-
`Failed to construct 'Response': The status provided (${init.status}) is outside the range [200, 599].`
118-
)
119-
}
120-
}
121-
122-
if ('statusText' in init && init.statusText !== undefined) {
123-
// 2. If init["statusText"] does not match the reason-phrase token
124-
// production, then throw a TypeError.
125-
// See, https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2:
126-
// reason-phrase = *( HTAB / SP / VCHAR / obs-text )
127-
if (!isValidReasonPhrase(String(init.statusText))) {
128-
throw new TypeError('Invalid statusText')
129-
}
130-
}
131-
132-
// 3. Set this’s response to a new response.
152+
// 1. Set this’s response to a new response.
133153
this[kState] = makeResponse({})
134154

135-
// 4. Set this’s headers to a new Headers object with this’s relevant
155+
// 2. Set this’s headers to a new Headers object with this’s relevant
136156
// Realm, whose header list is this’s response’s header list and guard
137157
// is "response".
138158
this[kHeaders] = new Headers()
139159
this[kHeaders][kGuard] = 'response'
140160
this[kHeaders][kHeadersList] = this[kState].headersList
141161
this[kHeaders][kRealm] = this[kRealm]
142162

143-
// 5. Set this’s response’s status to init["status"].
144-
if ('status' in init && init.status !== undefined) {
145-
this[kState].status = init.status
146-
}
147-
148-
// 6. Set this’s response’s status message to init["statusText"].
149-
if ('statusText' in init && init.statusText !== undefined) {
150-
this[kState].statusText = String(init.statusText)
151-
}
152-
153-
// 7. If init["headers"] exists, then fill this’s headers with init["headers"].
154-
if ('headers' in init) {
155-
fill(this[kState].headersList, init.headers)
156-
}
163+
// 3. Let bodyWithType be null.
164+
let bodyWithType = null
157165

158-
// 8. If body is non-null, then:
166+
// 4. If body is non-null, then set bodyWithType to the result of extracting body.
159167
if (body != null) {
160-
// 1. If init["status"] is a null body status, then throw a TypeError.
161-
if (nullBodyStatus.includes(init.status)) {
162-
throw new TypeError('Response with null body status cannot have body')
163-
}
164-
165-
// 2. Let Content-Type be null.
166-
// 3. Set this’s response’s body and Content-Type to the result of
167-
// extracting body.
168-
const [extractedBody, contentType] = extractBody(body)
169-
this[kState].body = extractedBody
170-
171-
// 4. If Content-Type is non-null and this’s response’s header list does
172-
// not contain `Content-Type`, then append `Content-Type`/Content-Type
173-
// to this’s response’s header list.
174-
if (contentType && !this.headers.has('content-type')) {
175-
this.headers.append('content-type', contentType)
176-
}
168+
const [extractedBody, type] = extractBody(body)
169+
bodyWithType = { body: extractedBody, type }
177170
}
171+
172+
// 5. Perform initialize a response given this, init, and bodyWithType.
173+
initializeResponse(this, init, bodyWithType)
178174
}
179175

180176
get [Symbol.toStringTag] () {
@@ -473,6 +469,57 @@ function makeAppropriateNetworkError (fetchParams) {
473469
: makeNetworkError(fetchParams.controller.terminated.reason)
474470
}
475471

472+
// https://whatpr.org/fetch/1392.html#initialize-a-response
473+
function initializeResponse (response, init, body) {
474+
// 1. If init["status"] is not in the range 200 to 599, inclusive, then
475+
// throw a RangeError.
476+
if (init.status != null && (init.status < 200 || init.status > 599)) {
477+
throw new RangeError('init["status"] must be in the range of 200 to 599, inclusive.')
478+
}
479+
480+
// 2. If init["statusText"] does not match the reason-phrase token production,
481+
// then throw a TypeError.
482+
if ('statusText' in init && init.statusText != null) {
483+
// See, https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2:
484+
// reason-phrase = *( HTAB / SP / VCHAR / obs-text )
485+
if (!isValidReasonPhrase(String(init.statusText))) {
486+
throw new TypeError('Invalid statusText')
487+
}
488+
}
489+
490+
// 3. Set response’s response’s status to init["status"].
491+
if ('status' in init && init.status != null) {
492+
response[kState].status = init.status
493+
}
494+
495+
// 4. Set response’s response’s status message to init["statusText"].
496+
if ('statusText' in init && init.statusText != null) {
497+
response[kState].statusText = init.statusText
498+
}
499+
500+
// 5. If init["headers"] exists, then fill response’s headers with init["headers"].
501+
if ('headers' in init && init.headers != null) {
502+
fill(response[kState].headersList, init.headers)
503+
}
504+
505+
// 6. If body was given, then:
506+
if (body) {
507+
// 1. If response's status is a null body status, then throw a TypeError.
508+
if (nullBodyStatus.includes(response.status)) {
509+
throw new TypeError()
510+
}
511+
512+
// 2. Set response's body to body's body.
513+
response[kState].body = body.body
514+
515+
// 3. If body's type is non-null and response's header list does not contain
516+
// `Content-Type`, then append (`Content-Type`, body's type) to response's header list.
517+
if (body.type != null && !response[kState].headersList.has('Content-Type')) {
518+
response[kState].headersList.append('content-type', body.type)
519+
}
520+
}
521+
}
522+
476523
module.exports = {
477524
makeNetworkError,
478525
makeResponse,

lib/fetch/util.js

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
const { redirectStatus } = require('./constants')
44
const { performance } = require('perf_hooks')
55
const { isBlobLike, toUSVString, ReadableStreamFrom } = require('../core/util')
6+
const assert = require('assert')
67

78
let File
89

@@ -384,6 +385,23 @@ function normalizeMethod (method) {
384385
: method
385386
}
386387

388+
// https://infra.spec.whatwg.org/#serialize-a-javascript-value-to-a-json-string
389+
function serializeJavascriptValueToJSONString (value) {
390+
// 1. Let result be ? Call(%JSON.stringify%, undefined, « value »).
391+
const result = JSON.stringify(value)
392+
393+
// 2. If result is undefined, then throw a TypeError.
394+
if (result === undefined) {
395+
throw new TypeError('Value is not JSON serializable')
396+
}
397+
398+
// 3. Assert: result is a string.
399+
assert(typeof result === 'string')
400+
401+
// 4. Return result.
402+
return result
403+
}
404+
387405
module.exports = {
388406
isAborted,
389407
isCancelled,
@@ -413,5 +431,6 @@ module.exports = {
413431
isValidReasonPhrase,
414432
sameOrigin,
415433
CORBCheck,
416-
normalizeMethod
434+
normalizeMethod,
435+
serializeJavascriptValueToJSONString
417436
}

test/fetch/response-json.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
'use strict'
2+
3+
const { test } = require('tap')
4+
const { Response } = require('../../')
5+
6+
// https://github.com/web-platform-tests/wpt/pull/32825/
7+
8+
const APPLICATION_JSON = 'application/json'
9+
const FOO_BAR = 'foo/bar'
10+
11+
const INIT_TESTS = [
12+
[undefined, 200, '', APPLICATION_JSON, {}],
13+
[{ status: 400 }, 400, '', APPLICATION_JSON, {}],
14+
[{ statusText: 'foo' }, 200, 'foo', APPLICATION_JSON, {}],
15+
[{ headers: {} }, 200, '', APPLICATION_JSON, {}],
16+
[{ headers: { 'content-type': FOO_BAR } }, 200, '', FOO_BAR, {}],
17+
[{ headers: { 'x-foo': 'bar' } }, 200, '', APPLICATION_JSON, { 'x-foo': 'bar' }]
18+
]
19+
20+
test('Check response returned by static json() with init', async (t) => {
21+
for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) {
22+
const response = Response.json('hello world', init)
23+
t.equal(response.type, 'default', "Response's type is default")
24+
t.equal(response.status, expectedStatus, "Response's status is " + expectedStatus)
25+
t.equal(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText))
26+
t.equal(response.headers.get('content-type'), expectedContentType, "Response's content-type is " + expectedContentType)
27+
for (const key in expectedHeaders) {
28+
t.equal(response.headers.get(key), expectedHeaders[key], "Response's header " + key + ' is ' + JSON.stringify(expectedHeaders[key]))
29+
}
30+
31+
const data = await response.json()
32+
t.equal(data, 'hello world', "Response's body is 'hello world'")
33+
}
34+
35+
t.end()
36+
})
37+
38+
test('Throws TypeError when calling static json() with an invalid status', (t) => {
39+
const nullBodyStatus = [204, 205, 304]
40+
41+
for (const status of nullBodyStatus) {
42+
t.throws(() => {
43+
Response.json('hello world', { status })
44+
}, TypeError, `Throws TypeError when calling static json() with a status of ${status}`)
45+
}
46+
47+
t.end()
48+
})
49+
50+
test('Check static json() encodes JSON objects correctly', async (t) => {
51+
const response = Response.json({ foo: 'bar' })
52+
const data = await response.json()
53+
t.equal(typeof data, 'object', "Response's json body is an object")
54+
t.equal(data.foo, 'bar', "Response's json body is { foo: 'bar' }")
55+
56+
t.end()
57+
})
58+
59+
test('Check static json() throws when data is not encodable', (t) => {
60+
t.throws(() => {
61+
Response.json(Symbol('foo'))
62+
}, TypeError)
63+
64+
t.end()
65+
})
66+
67+
test('Check static json() throws when data is circular', (t) => {
68+
const a = { b: 1 }
69+
a.a = a
70+
71+
t.throws(() => {
72+
Response.json(a)
73+
}, TypeError)
74+
75+
t.end()
76+
})
77+
78+
test('Check static json() propagates JSON serializer errors', (t) => {
79+
class CustomError extends Error {
80+
name = 'CustomError'
81+
}
82+
83+
t.throws(() => {
84+
Response.json({ get foo () { throw new CustomError('bar') } })
85+
}, CustomError)
86+
87+
t.end()
88+
})
89+
90+
// note: these tests are not part of any WPTs
91+
test('unserializable values', (t) => {
92+
t.throws(() => {
93+
Response.json(Symbol('symbol'))
94+
}, TypeError)
95+
96+
t.throws(() => {
97+
Response.json(undefined)
98+
}, TypeError)
99+
100+
t.throws(() => {
101+
Response.json()
102+
}, TypeError)
103+
104+
t.end()
105+
})
106+
107+
test('invalid init', (t) => {
108+
t.throws(() => {
109+
Response.json(null, 3)
110+
}, TypeError)
111+
112+
t.end()
113+
})

test/fetch/response.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ test('arg validation', (t) => {
2828
new Response(null, {
2929
status: '600'
3030
})
31-
}, TypeError)
31+
}, RangeError)
3232
t.throws(() => {
3333
// eslint-disable-next-line
3434
new Response(null, {

test/types/fetch.test-d.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,19 @@ expectType<Response>(new Response(new BigInt64Array(), responseInit))
108108
expectType<Response>(new Response(new BigUint64Array(), responseInit))
109109
expectType<Response>(new Response(new ArrayBuffer(0), responseInit))
110110
expectType<Response>(Response.error())
111+
expectType<Response>(Response.json({ a: 'b' }))
112+
expectType<Response>(Response.json({}, { status: 200 }))
113+
expectType<Response>(Response.json({}, { statusText: 'OK' }))
114+
expectType<Response>(Response.json({}, { headers: {} }))
115+
expectType<Response>(Response.json(null))
111116
expectType<Response>(Response.redirect('https://example.com', 301))
112117
expectType<Response>(Response.redirect('https://example.com', 302))
113118
expectType<Response>(Response.redirect('https://example.com', 303))
114119
expectType<Response>(Response.redirect('https://example.com', 307))
115120
expectType<Response>(Response.redirect('https://example.com', 308))
116121
expectError(Response.redirect('https://example.com', NaN))
122+
expectError(Response.json())
123+
expectError(Response.json(null, 3))
117124

118125
expectType<void>(headers.append('key', 'value'))
119126
expectType<void>(headers.delete('key'))

types/fetch.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,5 +199,6 @@ export declare class Response implements BodyMixin {
199199
readonly clone: () => Response
200200

201201
static error (): Response
202+
static json(data: any, init?: ResponseInit): Response
202203
static redirect (url: string | URL, status: ResponseRedirectStatus): Response
203204
}

0 commit comments

Comments
 (0)