current commit
260702.3 · feat: support HTTP QUERY method (RFC 10008) (#5459)
'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()
})