Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add Response.json #1452

Merged
merged 3 commits into from May 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
}