对POST请求的数据进行编码

163 阅读7分钟

现在,当你去copilot.github.com时,迎接你的是这个例子:

async function isPositive(text) {
  const response = await fetch(`http://text-processing.com/api/sentiment/`, {
    method: 'POST',
    body: `text=${text}`,
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
  });
  const json = await response.json();
  return json.label === 'pos';
}

这很糟糕,可能会导致安全问题。这里是事情出错的地方:body: `text=${text}` 。未转码的文本被添加到一个有定义编码的格式中。这类似于SQL/HTML注入,因为打算作为 "值 "的东西被直接与格式交互。

我将会挖掘正确的方法,但也会在一些相关的、不太知名的 API 上漫步。

URLSearchParams

URLSearchParams 处理编码和解码 数据。这是很方便的,因为,嗯...application/x-www-form-urlencoded

application/x-www-form-urlencoded 格式在很多方面都是一个畸形的怪胎,是多年来实施意外和妥协的结果,导致了一套互操作性所需的要求,但绝不代表良好的设计实践。特别是,我们提醒读者要密切注意涉及字符编码和字节序列之间重复(在某些情况下是嵌套)转换的扭曲细节。不幸的是,由于HTML表格的盛行,这种格式得到了广泛的使用。

...所以是的,自己尝试编码/解码是个坏主意。下面是它的工作原理:

const searchParams = new URLSearchParams();
searchParams.set('foo', 'bar');
searchParams.set('hello', 'world');

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

构造函数也接受一个名/值对数组,或者一个产生名/值对的迭代器:

const searchParams = new URLSearchParams([
  ['foo', 'bar'],
  ['hello', 'world'],
]);

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

一个对象:

const searchParams = new URLSearchParams({
  foo: 'bar',
  hello: 'world',
});

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

或者一个字符串:

const searchParams = new URLSearchParams('foo=bar&hello=world');

// Logs 'foo=bar&hello=world'
console.log(searchParams.toString());

读取 URLSearchParams

有多种方法来读取和突变URLSearchParams ,这些方法在 MDN 上都有记录,但如果你想处理所有的数据,那么它的迭代器就会派上用场:

for (const [key, value] of searchParams) {
  console.log(key, value);
}

这意味着你可以很容易地把它转换成一个名/值对的数组:

// To [['foo', 'bar'], ['hello', 'world']]
const keyValuePairs = [...searchParams];

或者与支持产生名/值对的迭代器的API一起使用,如Object.fromEntries ,将其转换为一个对象:

// To { foo: 'bar', hello: 'world' }
const data = Object.fromEntries(searchParams);

但是,请注意,转换为对象有时是一种有损失的转换:

const searchParams = new URLSearchParams([
  ['foo', 'bar'],
  ['foo', 'hello'],
]);

// Logs "foo=bar&foo=hello"
console.log(searchParams.toString());

// To { foo: 'hello' }
const data = Object.fromEntries(searchParams);

url.searchParams

URL对象有一个searchParams 属性,这确实很方便:

const url = new URL('https://jakearchibald.com/?foo=bar&hello=world');

// Logs 'world'
console.log(url.searchParams.get('hello'));

不幸的是,location.searchParams 是未定义的。这是因为window.location 的定义很复杂,因为它的某些属性在不同的起源中起作用。例如,设置 otherWindow.location.href 在不同的起源中都是有效的,但得到它是不允许的。总之,为了解决这个问题:

// Boo, undefined
location.searchParams;

const url = new URL(location.href);
// Yay, defined!
url.searchParams;

// Or:
const searchParams = new URLSearchParams(location.search);

URLSearchParams 作为 Fetch body

好了,现在我们进入正题了。文章开头的例子中的代码是坏的,因为它没有对输入进行转义:

const value = 'hello&world';
const badEncoding = `text=${value}`;

// 😬 Logs [['text', 'hello'], ['world', '']]
console.log([...new URLSearchParams(badEncoding)]);

const correctEncoding = new URLSearchParams({ text: value });

// Logs 'text=hello%26world'
console.log(correctEncoding.toString());

为了方便起见,URLSearchParams 可以直接作为RequestResponse 的主体使用,所以文章开头的代码的 "正确 "版本是:

async function isPositive(text) {
  const response = await fetch(`http://text-processing.com/api/sentiment/`, {
    method: 'POST',
    body: new URLSearchParams({ text }),
  });
  const json = await response.json();
  return json.label === 'pos';
}

如果你使用URLSearchParams 作为主体,Content-Type 头部会自动设置为application/x-www-form-urlencoded ,这很好,因为即使做了20多年的网络开发者,我也永远记不住这种内容类型。你仍然可以提供你自己的Content-Type 标头来覆盖默认的内容。

你不能把RequestResponse 的正文读成URLSearchParams ,但也有办法解决这个问题......

表格数据

FormData 对象可以代表一个HTML表单的名称/值状态。这意味着这些值可以是文件,就像它们可以是 。<input type="file">

你可以直接填充FormData 状态:

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

它也是一个迭代器,所以它可以被转换为一个名/值对的数组,或者一个对象,就像你可以用URLSearchParams 。但是,与URLSearchParams 不同的是,你可以直接读取一个HTML表单,作为FormData

const formElement = document.querySelector('form');
const formData = new FormData(formElement);
console.log(formData.get('username'));

这样你就可以得到表单提交的数据。我经常发现这比从每个元素中获取数据要容易得多:

将FormData作为Fetch主体

URLSearchParams 类似,你可以直接使用FormData 作为获取主体:

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

fetch(url, {
  method: 'POST',
  body: formData,
});

这将自动把Content-Type 头部设置为multipart/form-data ,并以这种格式发送数据:

const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');

const request = new Request('', { method: 'POST', body: formData });
console.log(await request.text());

......它记录的东西是:

------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="foo"

bar
------WebKitFormBoundaryUekOXqmLphEavsu5
Content-Disposition: form-data; name="hello"

world
------WebKitFormBoundaryUekOXqmLphEavsu5--

这就是multipart/form-data 的样子。它比application/x-www-form-urlencoded 更复杂,但它可以包括文件数据。然而,有些服务器不能处理multipart/form-data ,包括Express。如果你想在Express中支持multipart/form-data ,你需要使用像busboyformidable这样的东西。

但是,如果你想把一个表单作为application/x-www-form-urlencoded 发送呢? 那么......

转换为 URLSearchParams

由于URLSearchParams 构造函数接受一个产生名/值对的迭代器,而FormData 的迭代器正是这样做的,你可以从一个转换到另一个:

const formElement = document.querySelector('form');
const formData = new FormData(formElement);
const searchParams = new URLSearchParams(formData);

fetch(url, {
  method: 'POST',
  body: searchParams,
});

然而,如果表单数据包含一个文件,这种转换将被抛出。application/x-www-form-urlencoded 不能表示文件数据,所以URLSearchParams 也不能。

读取作为表单数据的Fetch体

你也可以把RequestResponse 对象作为FormData

const formData = await request.formData();

如果请求/响应体是multipart/form-dataapplication/x-www-form-urlencoded ,这就可以了。这对于处理服务工作者中的表单提交特别有用。

其他获取体

还有一些其他格式可以作为获取体。

BLOB

Blob 对象(因此 ,因为它继承自 )可以成为获取体。File Blob

fetch(url, {
  method: 'POST',
  body: blob,
});

这将自动把Content-Type 设置为blob.type 的值。

字符串

fetch(url, {
  method: 'POST',
  body: JSON.stringify({ hello: 'world' }),
  headers: { 'Content-Type': 'application/json' },
});

这将自动把Content-Type 设置为text/plain;charset=UTF-8 ,但正如我在上面所做的那样,它总是可以被覆盖。

缓冲器

ArrayBuffer 对象,以及任何由数组缓冲区支持的东西,比如 ,都可以作为一个获取主体。Uint8Array

fetch(url, {
  method: 'POST',
  body: new Uint8Array([
    // …
  ]),
  headers: { 'Content-Type': 'image/png' },
});

这不会自动设置Content-Type 头部,所以你需要自己做这个。

最后,获取体可以是流对于Response 对象,这允许与服务工作者一起使用各种乐趣,而且最近他们也可以与请求一起使用

所以,不要试图自己处理multipart/form-dataapplication/x-www-form-urlencoded ,让FormDataURLSearchParams 来做这些艰苦的工作!

我也不反对像GitHub Copilot这样的东西。只要把输出结果当作StackOverflow上的答案,并在提交之前审查它。

奖励回合。将FormData转换为JSON

Nicholas Mendez在推特上,如何在不丢失数据的情况下将FormData 串行化为JSON。

表格可以包含这样的字段:

<select multiple name="tvShows">
  <option>Motherland</option>
  <option>Taskmaster</option>
  …
</select>

...其中可以选择多个值,或者你可以有多个相同名称的输入:

<fieldset>
  <legend>TV Shows</legend>
  <label>
    <input type="checkbox" name="tvShows" value="Motherland" />
    Motherland
  </label>
  <label>
    <input type="checkbox" name="tvShows" value="Taskmaster" />
    Taskmaster
  </label></fieldset>

其结果是一个FormData 对象,该对象有多个同名的条目,像这样:

const formData = new FormData();
formData.append('foo', 'bar');
formData.append('tvShows', 'Motherland');
formData.append('tvShows', 'Taskmaster');

正如我们在URLSearchParams ,一些对象的转换是有损失的:

// { foo: 'bar', tvShows: 'Taskmaster' }
const data = Object.fromEntries(formData);

有几种方法可以避免数据丢失,并最终得到可被JSON字符串化的东西。首先,有一个名/值对的数组:

// [['foo', 'bar'], ['tvShows', 'Motherland'], ['tvShows', 'Taskmaster']]
const data = [...formData];

但是如果你想要一个对象而不是一个数组,你可以这样做:

const data = Object.fromEntries(
  // Get a de-duped set of keys
  [...new Set(formData.keys())]
    // Map to [key, arrayOfValues]
    .map((key) => [key, formData.getAll(key)]),
);

...这样你就可以得到:

{
  "foo": ["bar"],
  "tvShows": ["Motherland", "Taskmaster"]
}

我喜欢每个值都是一个数组,即使它只有一个项目。这可以防止在服务器上出现大量的代码分支,并简化了验证工作。不过,你可能更喜欢PHP/Perl的惯例,即一个以[] 结尾的字段名表示 "这应该产生一个数组":

<select multiple name="tvShows[]">
  …
</select>

并将其转换:

const data = Object.fromEntries(
  // Get a de-duped set of keys
  [...new Set(formData.keys())].map((key) =>
    key.endsWith('[]')
      ? // Remove [] from the end and get an array of values
        [key.slice(0, -2), formData.getAll(key)]
      : // Use the key as-is and get a single value
        [key, formData.get(key)],
  ),
);

......这样就可以了:

{
  "foo": "bar",
  "tvShows": ["Motherland", "Taskmaster"]
}

记住,如果表单包含文件,不要试图将其转换为JSON。如果是这种情况,你最好使用multipart/form-data