Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/core/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ class Request {
this.protocol = getProtocolFromUrlString(origin)

this.idempotent = idempotent == null
? method === 'HEAD' || method === 'GET'
? method === 'HEAD' || method === 'GET' || method === 'QUERY'
: idempotent

this.blocking = blocking ?? this.method !== 'HEAD'
Expand Down
4 changes: 3 additions & 1 deletion lib/core/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -960,7 +960,9 @@ const normalizedMethodRecordsBase = {
post: 'POST',
POST: 'POST',
put: 'PUT',
PUT: 'PUT'
PUT: 'PUT',
query: 'QUERY',
QUERY: 'QUERY'
}

const normalizedMethodRecords = {
Expand Down
1 change: 1 addition & 0 deletions lib/handler/redirect-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ class RedirectHandler {
// https://tools.ietf.org/html/rfc7231#section-6.4.2
// https://fetch.spec.whatwg.org/#http-redirect-fetch
// In case of HTTP 301 or 302 with POST, change the method to GET
// QUERY is safe (RFC 10008) and should not change method like GET.
if ((statusCode === 301 || statusCode === 302) && this.opts.method === 'POST') {
this.opts.method = 'GET'
if (util.isStream(this.opts.body)) {
Expand Down
2 changes: 1 addition & 1 deletion lib/handler/retry-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class RetryHandler {
timeoutFactor: timeoutFactor ?? 2,
maxRetries: maxRetries ?? 5,
// What errors we should retry
methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE'],
methods: methods ?? ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE', 'TRACE', 'QUERY'],
// Indicates which errors to retry
statusCodes: statusCodes ?? [500, 502, 503, 504, 429],
// List of errors to retry
Expand Down
2 changes: 1 addition & 1 deletion lib/web/fetch/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const referrerPolicyTokensSet = new Set(referrerPolicyTokens)

const requestRedirect = /** @type {const} */ (['follow', 'manual', 'error'])

const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE'])
const safeMethods = /** @type {const} */ (['GET', 'HEAD', 'OPTIONS', 'TRACE', 'QUERY'])
const safeMethodsSet = new Set(safeMethods)

const requestMode = /** @type {const} */ (['navigate', 'same-origin', 'no-cors', 'cors'])
Expand Down
298 changes: 298 additions & 0 deletions test/query.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
'use strict'

const assert = require('node:assert')
const { test } = require('node:test')
const { createServer } = require('node:http')
const { once } = require('node:events')
const { Readable } = require('node:stream')
const { Client, errors, fetch, FormData } = require('..')

test('QUERY with string body sends correctly', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
// Consume body to ensure it arrives
for await (const _ of req) {} // eslint-disable-line
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{"data":{}}')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
headers: { 'content-type': 'application/json' },
body: '{"query":"{user}"}'
})
await response.body.text()
})

test('QUERY with Buffer body sends correctly', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
// Consume body to ensure it arrives
for await (const _ of req) {} // eslint-disable-line
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
body: Buffer.from('hello')
})
await response.body.text()
})

test('QUERY with stream body sends correctly', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
// Consume body to ensure it arrives
for await (const _ of req) {} // eslint-disable-line
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
body: new Readable({
read () {
this.push('streamed')
this.push(null)
}
})
})
await response.body.text()
})

test('QUERY without body works', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
body: ''
})
await response.body.text()
})

test('QUERY with null body works', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
body: null
})
await response.body.text()
})

test('QUERY sends Content-Type header', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
headers: { 'content-type': 'application/json' },
body: '{}'
})
await response.body.text()
})

test('QUERY can send Accept-Query header', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
headers: { 'accept-query': 'application/graphql' },
body: '{}'
})
await response.body.text()
})

test('QUERY sends content-length header', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
path: '/',
method: 'QUERY',
body: 'hello'
})
await response.body.text()
})

test('QUERY with content-length mismatch should error', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.end()
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

try {
await client.request({
path: '/',
method: 'QUERY',
headers: { 'content-length': 10 },
body: 'asd'
})
assert.fail('should have thrown')
} catch (err) {
assert.ok(err instanceof errors.RequestContentLengthMismatchError)
}
})

test('QUERY method is recognized as safe in fetch API', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
res.writeHead(200, { 'content-type': 'text/plain' })
res.end('ok')
})

await once(server.listen(0), 'listening')
t.after(() => server.close())

const resp = await fetch(`http://localhost:${server.address().port}/`, {
method: 'QUERY',
headers: { 'content-type': 'text/plain' },
body: 'hello'
})
assert.strictEqual(resp.status, 200)
const text = await resp.text()
assert.strictEqual(text, 'ok')
})

test('QUERY with FormData body works', async (t) => {
const server = createServer({ joinDuplicateHeaders: true }, async (req, res) => {
// Consume body to ensure it arrives
for await (const _ of req) {} // eslint-disable-line
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`)
t.after(() => { client.destroy(); server.close() })

const fd = new FormData()
fd.append('field', 'value')

const response = await client.request({
method: 'QUERY',
path: '/',
body: fd
})
await response.body.text()
})

test('QUERY redirect 301 should NOT change method (QUERY is safe)', async (t) => {
let redirects = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
redirects++
if (redirects === 1) {
res.writeHead(301, { location: '/redirected' })
res.end()
return
}
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`, {
maxRedirections: 1
})
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
body: 'hello',
headers: { 'content-type': 'text/plain' }
})
await response.body.text()
})

test('QUERY redirect 303 should change method to GET', async (t) => {
let redirects = 0
const server = createServer({ joinDuplicateHeaders: true }, (req, res) => {
redirects++
if (redirects === 1) {
res.writeHead(303, { location: '/redirected' })
res.end()
return
}
res.writeHead(200)
res.end('ok')
})

await once(server.listen(0), 'listening')

const client = new Client(`http://localhost:${server.address().port}`, {
maxRedirections: 1
})
t.after(() => { client.destroy(); server.close() })

const response = await client.request({
method: 'QUERY',
path: '/',
body: 'hello',
headers: { 'content-type': 'text/plain' }
})
await response.body.text()
})
Loading