Skip to content

Commit

Permalink
feat: add Response.json (nodejs#1452)
Browse files Browse the repository at this point in the history
* feat: add `Response.json`

* fix: serialize value properly

* fix: types & more coverage
  • Loading branch information
KhafraDev authored and crysmags committed Feb 27, 2024
1 parent d0d6f73 commit 0818224
Show file tree
Hide file tree
Showing 6 changed files with 248 additions and 61 deletions.
165 changes: 106 additions & 59 deletions lib/fetch/response.js
Expand Up @@ -5,7 +5,7 @@ const { AbortError } = require('../core/errors')
const { extractBody, cloneBody, mixinBody } = require('./body')
const util = require('../core/util')
const { kEnumerableProperty } = util
const { responseURL, isValidReasonPhrase, toUSVString, isCancelled, isAborted } = require('./util')
const { responseURL, isValidReasonPhrase, toUSVString, isCancelled, isAborted, serializeJavascriptValueToJSONString } = require('./util')
const {
redirectStatus,
nullBodyStatus,
Expand Down Expand Up @@ -35,6 +35,50 @@ class Response {
return responseObject
}

// https://fetch.spec.whatwg.org/#dom-response-json
static json (data, init = {}) {
if (arguments.length === 0) {
throw new TypeError(
'Failed to execute \'json\' on \'Response\': 1 argument required, but 0 present.'
)
}

if (init === null || typeof init !== 'object') {
throw new TypeError(
`Failed to execute 'json' on 'Response': init must be a RequestInit, found ${typeof init}.`
)
}

init = {
status: 200,
statusText: '',
headers: new HeadersList(),
...init
}

// 1. Let bytes the result of running serialize a JavaScript value to JSON bytes on data.
const bytes = new TextEncoder('utf-8').encode(
serializeJavascriptValueToJSONString(data)
)

// 2. Let body be the result of extracting bytes.
const body = extractBody(bytes)

// 3. Let responseObject be the result of creating a Response object, given a new response,
// "response", and this’s relevant Realm.
const relevantRealm = { settingsObject: {} }
const responseObject = new Response()
responseObject[kRealm] = relevantRealm
responseObject[kHeaders][kGuard] = 'response'
responseObject[kHeaders][kRealm] = relevantRealm

// 4. Perform initialize a response given responseObject, init, and (body, "application/json").
initializeResponse(responseObject, init, { body: body[0], type: 'application/json' })

// 5. Return responseObject.
return responseObject
}

// Creates a redirect Response that redirects to url with status status.
static redirect (...args) {
const relevantRealm = { settingsObject: {} }
Expand Down Expand Up @@ -105,76 +149,28 @@ class Response {
// TODO
this[kRealm] = { settingsObject: {} }

// 1. If init["status"] is not in the range 200 to 599, inclusive, then
// throw a RangeError.
if ('status' in init && init.status !== undefined) {
if (!Number.isFinite(init.status)) {
throw new TypeError()
}

if (init.status < 200 || init.status > 599) {
throw new RangeError(
`Failed to construct 'Response': The status provided (${init.status}) is outside the range [200, 599].`
)
}
}

if ('statusText' in init && init.statusText !== undefined) {
// 2. If init["statusText"] does not match the reason-phrase token
// production, then throw a TypeError.
// See, https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2:
// reason-phrase = *( HTAB / SP / VCHAR / obs-text )
if (!isValidReasonPhrase(String(init.statusText))) {
throw new TypeError('Invalid statusText')
}
}

// 3. Set this’s response to a new response.
// 1. Set this’s response to a new response.
this[kState] = makeResponse({})

// 4. Set this’s headers to a new Headers object with this’s relevant
// 2. Set this’s headers to a new Headers object with this’s relevant
// Realm, whose header list is this’s response’s header list and guard
// is "response".
this[kHeaders] = new Headers()
this[kHeaders][kGuard] = 'response'
this[kHeaders][kHeadersList] = this[kState].headersList
this[kHeaders][kRealm] = this[kRealm]

// 5. Set this’s response’s status to init["status"].
if ('status' in init && init.status !== undefined) {
this[kState].status = init.status
}

// 6. Set this’s response’s status message to init["statusText"].
if ('statusText' in init && init.statusText !== undefined) {
this[kState].statusText = String(init.statusText)
}

// 7. If init["headers"] exists, then fill this’s headers with init["headers"].
if ('headers' in init) {
fill(this[kState].headersList, init.headers)
}
// 3. Let bodyWithType be null.
let bodyWithType = null

// 8. If body is non-null, then:
// 4. If body is non-null, then set bodyWithType to the result of extracting body.
if (body != null) {
// 1. If init["status"] is a null body status, then throw a TypeError.
if (nullBodyStatus.includes(init.status)) {
throw new TypeError('Response with null body status cannot have body')
}

// 2. Let Content-Type be null.
// 3. Set this’s response’s body and Content-Type to the result of
// extracting body.
const [extractedBody, contentType] = extractBody(body)
this[kState].body = extractedBody

// 4. If Content-Type is non-null and this’s response’s header list does
// not contain `Content-Type`, then append `Content-Type`/Content-Type
// to this’s response’s header list.
if (contentType && !this.headers.has('content-type')) {
this.headers.append('content-type', contentType)
}
const [extractedBody, type] = extractBody(body)
bodyWithType = { body: extractedBody, type }
}

// 5. Perform initialize a response given this, init, and bodyWithType.
initializeResponse(this, init, bodyWithType)
}

get [Symbol.toStringTag] () {
Expand Down Expand Up @@ -473,6 +469,57 @@ function makeAppropriateNetworkError (fetchParams) {
: makeNetworkError(fetchParams.controller.terminated.reason)
}

// https://whatpr.org/fetch/1392.html#initialize-a-response
function initializeResponse (response, init, body) {
// 1. If init["status"] is not in the range 200 to 599, inclusive, then
// throw a RangeError.
if (init.status != null && (init.status < 200 || init.status > 599)) {
throw new RangeError('init["status"] must be in the range of 200 to 599, inclusive.')
}

// 2. If init["statusText"] does not match the reason-phrase token production,
// then throw a TypeError.
if ('statusText' in init && init.statusText != null) {
// See, https://datatracker.ietf.org/doc/html/rfc7230#section-3.1.2:
// reason-phrase = *( HTAB / SP / VCHAR / obs-text )
if (!isValidReasonPhrase(String(init.statusText))) {
throw new TypeError('Invalid statusText')
}
}

// 3. Set response’s response’s status to init["status"].
if ('status' in init && init.status != null) {
response[kState].status = init.status
}

// 4. Set response’s response’s status message to init["statusText"].
if ('statusText' in init && init.statusText != null) {
response[kState].statusText = init.statusText
}

// 5. If init["headers"] exists, then fill response’s headers with init["headers"].
if ('headers' in init && init.headers != null) {
fill(response[kState].headersList, init.headers)
}

// 6. If body was given, then:
if (body) {
// 1. If response's status is a null body status, then throw a TypeError.
if (nullBodyStatus.includes(response.status)) {
throw new TypeError()
}

// 2. Set response's body to body's body.
response[kState].body = body.body

// 3. If body's type is non-null and response's header list does not contain
// `Content-Type`, then append (`Content-Type`, body's type) to response's header list.
if (body.type != null && !response[kState].headersList.has('Content-Type')) {
response[kState].headersList.append('content-type', body.type)
}
}
}

module.exports = {
makeNetworkError,
makeResponse,
Expand Down
21 changes: 20 additions & 1 deletion lib/fetch/util.js
Expand Up @@ -3,6 +3,7 @@
const { redirectStatus } = require('./constants')
const { performance } = require('perf_hooks')
const { isBlobLike, toUSVString, ReadableStreamFrom } = require('../core/util')
const assert = require('assert')

let File

Expand Down Expand Up @@ -384,6 +385,23 @@ function normalizeMethod (method) {
: method
}

// https://infra.spec.whatwg.org/#serialize-a-javascript-value-to-a-json-string
function serializeJavascriptValueToJSONString (value) {
// 1. Let result be ? Call(%JSON.stringify%, undefined, « value »).
const result = JSON.stringify(value)

// 2. If result is undefined, then throw a TypeError.
if (result === undefined) {
throw new TypeError('Value is not JSON serializable')
}

// 3. Assert: result is a string.
assert(typeof result === 'string')

// 4. Return result.
return result
}

module.exports = {
isAborted,
isCancelled,
Expand Down Expand Up @@ -413,5 +431,6 @@ module.exports = {
isValidReasonPhrase,
sameOrigin,
CORBCheck,
normalizeMethod
normalizeMethod,
serializeJavascriptValueToJSONString
}
113 changes: 113 additions & 0 deletions test/fetch/response-json.js
@@ -0,0 +1,113 @@
'use strict'

const { test } = require('tap')
const { Response } = require('../../')

// https://github.com/web-platform-tests/wpt/pull/32825/

const APPLICATION_JSON = 'application/json'
const FOO_BAR = 'foo/bar'

const INIT_TESTS = [
[undefined, 200, '', APPLICATION_JSON, {}],
[{ status: 400 }, 400, '', APPLICATION_JSON, {}],
[{ statusText: 'foo' }, 200, 'foo', APPLICATION_JSON, {}],
[{ headers: {} }, 200, '', APPLICATION_JSON, {}],
[{ headers: { 'content-type': FOO_BAR } }, 200, '', FOO_BAR, {}],
[{ headers: { 'x-foo': 'bar' } }, 200, '', APPLICATION_JSON, { 'x-foo': 'bar' }]
]

test('Check response returned by static json() with init', async (t) => {
for (const [init, expectedStatus, expectedStatusText, expectedContentType, expectedHeaders] of INIT_TESTS) {
const response = Response.json('hello world', init)
t.equal(response.type, 'default', "Response's type is default")
t.equal(response.status, expectedStatus, "Response's status is " + expectedStatus)
t.equal(response.statusText, expectedStatusText, "Response's statusText is " + JSON.stringify(expectedStatusText))
t.equal(response.headers.get('content-type'), expectedContentType, "Response's content-type is " + expectedContentType)
for (const key in expectedHeaders) {
t.equal(response.headers.get(key), expectedHeaders[key], "Response's header " + key + ' is ' + JSON.stringify(expectedHeaders[key]))
}

const data = await response.json()
t.equal(data, 'hello world', "Response's body is 'hello world'")
}

t.end()
})

test('Throws TypeError when calling static json() with an invalid status', (t) => {
const nullBodyStatus = [204, 205, 304]

for (const status of nullBodyStatus) {
t.throws(() => {
Response.json('hello world', { status })
}, TypeError, `Throws TypeError when calling static json() with a status of ${status}`)
}

t.end()
})

test('Check static json() encodes JSON objects correctly', async (t) => {
const response = Response.json({ foo: 'bar' })
const data = await response.json()
t.equal(typeof data, 'object', "Response's json body is an object")
t.equal(data.foo, 'bar', "Response's json body is { foo: 'bar' }")

t.end()
})

test('Check static json() throws when data is not encodable', (t) => {
t.throws(() => {
Response.json(Symbol('foo'))
}, TypeError)

t.end()
})

test('Check static json() throws when data is circular', (t) => {
const a = { b: 1 }
a.a = a

t.throws(() => {
Response.json(a)
}, TypeError)

t.end()
})

test('Check static json() propagates JSON serializer errors', (t) => {
class CustomError extends Error {
name = 'CustomError'
}

t.throws(() => {
Response.json({ get foo () { throw new CustomError('bar') } })
}, CustomError)

t.end()
})

// note: these tests are not part of any WPTs
test('unserializable values', (t) => {
t.throws(() => {
Response.json(Symbol('symbol'))
}, TypeError)

t.throws(() => {
Response.json(undefined)
}, TypeError)

t.throws(() => {
Response.json()
}, TypeError)

t.end()
})

test('invalid init', (t) => {
t.throws(() => {
Response.json(null, 3)
}, TypeError)

t.end()
})
2 changes: 1 addition & 1 deletion test/fetch/response.js
Expand Up @@ -28,7 +28,7 @@ test('arg validation', (t) => {
new Response(null, {
status: '600'
})
}, TypeError)
}, RangeError)
t.throws(() => {
// eslint-disable-next-line
new Response(null, {
Expand Down
7 changes: 7 additions & 0 deletions test/types/fetch.test-d.ts
Expand Up @@ -108,12 +108,19 @@ expectType<Response>(new Response(new BigInt64Array(), responseInit))
expectType<Response>(new Response(new BigUint64Array(), responseInit))
expectType<Response>(new Response(new ArrayBuffer(0), responseInit))
expectType<Response>(Response.error())
expectType<Response>(Response.json({ a: 'b' }))
expectType<Response>(Response.json({}, { status: 200 }))
expectType<Response>(Response.json({}, { statusText: 'OK' }))
expectType<Response>(Response.json({}, { headers: {} }))
expectType<Response>(Response.json(null))
expectType<Response>(Response.redirect('https://example.com', 301))
expectType<Response>(Response.redirect('https://example.com', 302))
expectType<Response>(Response.redirect('https://example.com', 303))
expectType<Response>(Response.redirect('https://example.com', 307))
expectType<Response>(Response.redirect('https://example.com', 308))
expectError(Response.redirect('https://example.com', NaN))
expectError(Response.json())
expectError(Response.json(null, 3))

expectType<void>(headers.append('key', 'value'))
expectType<void>(headers.delete('key'))
Expand Down
1 change: 1 addition & 0 deletions types/fetch.d.ts
Expand Up @@ -199,5 +199,6 @@ export declare class Response implements BodyMixin {
readonly clone: () => Response

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

0 comments on commit 0818224

Please sign in to comment.