diff --git a/lib/fetch/response.js b/lib/fetch/response.js index eab18b1855a..03db7eb488e 100644 --- a/lib/fetch/response.js +++ b/lib/fetch/response.js @@ -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, @@ -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: {} } @@ -105,34 +149,10 @@ 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() @@ -140,41 +160,17 @@ class 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] () { @@ -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, diff --git a/lib/fetch/util.js b/lib/fetch/util.js index 0116f67a8b6..bfa8fdee73e 100644 --- a/lib/fetch/util.js +++ b/lib/fetch/util.js @@ -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 @@ -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, @@ -413,5 +431,6 @@ module.exports = { isValidReasonPhrase, sameOrigin, CORBCheck, - normalizeMethod + normalizeMethod, + serializeJavascriptValueToJSONString } diff --git a/test/fetch/response-json.js b/test/fetch/response-json.js new file mode 100644 index 00000000000..6244fbfe99e --- /dev/null +++ b/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() +}) diff --git a/test/fetch/response.js b/test/fetch/response.js index 7a8fb15203f..374c84034e9 100644 --- a/test/fetch/response.js +++ b/test/fetch/response.js @@ -28,7 +28,7 @@ test('arg validation', (t) => { new Response(null, { status: '600' }) - }, TypeError) + }, RangeError) t.throws(() => { // eslint-disable-next-line new Response(null, { diff --git a/test/types/fetch.test-d.ts b/test/types/fetch.test-d.ts index 1cd5f071799..43092d62023 100644 --- a/test/types/fetch.test-d.ts +++ b/test/types/fetch.test-d.ts @@ -108,12 +108,19 @@ expectType(new Response(new BigInt64Array(), responseInit)) expectType(new Response(new BigUint64Array(), responseInit)) expectType(new Response(new ArrayBuffer(0), responseInit)) expectType(Response.error()) +expectType(Response.json({ a: 'b' })) +expectType(Response.json({}, { status: 200 })) +expectType(Response.json({}, { statusText: 'OK' })) +expectType(Response.json({}, { headers: {} })) +expectType(Response.json(null)) expectType(Response.redirect('https://example.com', 301)) expectType(Response.redirect('https://example.com', 302)) expectType(Response.redirect('https://example.com', 303)) expectType(Response.redirect('https://example.com', 307)) expectType(Response.redirect('https://example.com', 308)) expectError(Response.redirect('https://example.com', NaN)) +expectError(Response.json()) +expectError(Response.json(null, 3)) expectType(headers.append('key', 'value')) expectType(headers.delete('key')) diff --git a/types/fetch.d.ts b/types/fetch.d.ts index 8b4d56fed68..47a08dd0510 100644 --- a/types/fetch.d.ts +++ b/types/fetch.d.ts @@ -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 }