跳到主要内容

在获取时转换数据

所有网络请求都会经过 fetch() 方法,因此任何需要的转换都可以简单地通过覆盖它并调用 super 来完成。

提示

注意:如果你能掌控 API 的设计,通常更推荐直接修改通过网络发送的数据。让客户端尽可能保持 thin,对性能和复杂度都有好处。

不过,在很多情况下,你需要使用自己无法掌控的 API—— 可能是公共 API,也可能是由于内部组织结构的原因。

从蛇形到驼峰​

API 的键通常采用 snake_case 设计,但许多 typescript/javascript 开发者更喜欢 camelCase。下面这段代码可以完成所需的转换。

CamelResource.ts
import { camelCase, snakeCase } from 'lodash';
import { RestEndpoint, RestGenerics } from '@data-client/rest';

function deeplyApplyKeyTransform(obj: any, transform: (key: string) => string) {
const ret: Record<string, any> = Array.isArray(obj) ? [] : {};
Object.keys(obj).forEach(key => {
if (obj[key] != null && typeof obj[key] === 'object') {
ret[transform(key)] = deeplyApplyKeyTransform(obj[key], transform);
} else {
ret[transform(key)] = obj[key];
}
});
return ret;
}

class CamelEndpoint<O Extends RestGenerics = any> extends RestEndpoint<O> {
getRequestInit(body) {
// we'll need to do the inverse operation when sending data back to the server
if (body) {
return super.getRequestInit(deeplyApplyKeyTransform(body, snakeCase));
}
return super.getRequestInit(body);
}
process(value) {
return deeplyApplyKeyTransform(value, camelCase);
}
}

反序列化字段​

由于 JSON 只有少数几种基本类型,通过 JSON 发送的数据在很多情况下会被序列化为字符串。常见的例子包括用 ISO 8601 表示日期,甚至用字符串表示需要高精度的小数(浮点数可能有精度损失)。让数据保持序列化形式通常没有问题,尤其是当它只用于展示时。然而,当需要计算派生数据时,比如给日期加上一段时间或将两个数相乘,这就会带来问题。

这种情况下,只需将 static schema 与 Temporal.Instant 和 BigNumber 搭配使用

Fixtures
GET /price/btc-usd
{"exchangePair":"btc-usd","price":"32982389239823983298329832.238923982389328932893298","updatedAt":"2026-01-01T12:00:00.000Z"}
▶api/Price
import { Entity, RestEndpoint } from '@data-client/rest';
import { Temporal } from 'temporal-polyfill';
import BigNumber from 'bignumber.js';

export class ExchangePrice extends Entity {
  exchangePair = '';
  updatedAt = Temporal.Instant.fromEpochMilliseconds(0);
  price = new BigNumber(0);
  pk() {
    return this.exchangePair;
  }
  static key = 'ExchangePrice';

  static schema = {
    updatedAt: Temporal.Instant.from,
    price: BigNumber,
  };
}
export const getPrice = new RestEndpoint({
  path: '/price/:exchangePair',
  schema: ExchangePrice,
});
▶PricePage
import { useSuspense } from '@data-client/react';
import { getPrice } from './api/Price';

function PricePage() {
  const currentPrice = useSuspense(getPrice, {
    exchangePair: 'btc-usd',
  });
  return (
    <div>
      ${currentPrice.price.toFormat(2)} as of{' '}
      <time>
        {currentPrice.updatedAt.toLocaleString('en-US', { dateStyle: 'medium' })}
      </time>
    </div>
  );
}
render(<PricePage />);
结果
Store▶

反序列化 Date​

如果你想使用传统的 Date,可以把构造函数包装成一个函数 schema。

export class ExchangePrice extends Entity {
exchangePair = '';
updatedAt = new Date(0);
price = new BigNumber(0);
pk() {
return this.exchangePair;
}
static key = 'ExchangePrice';

static schema = {
updatedAt: iso => new Date(iso),
price: BigNumber,
};
}

缺失 Id 的情况​

现在你想对接一个很棒的新直播网站 mystreamsite.tv。它提供了一个简单的 API,用于获取当前直播的信息。你可以通过 url 模式 https://mystreamsite.tv/[username]/ 获取某个直播。然而,出于某种原因,它们并没有在响应体中返回用户名!而你需要引用它,因为它是这个类唯一的标识符。

我们可以直接从请求 url 中解析出用户名,并将其添加到响应中。

GET https://mystreamsite.tv/ntucker/
{
"title": "When I'm Grandmaster, I will play faster.",
"game": "Starcraft II",
"current_viewers": 1337,
"live": true
}
api/Stream.ts
const USERNAME_MATCHER = /.*\/([^\/]+)\/?/;

class Stream extends Entity {
username = '';
title = '';
game = '';
currentViewers = 0;
live = false;

pk() {
return this.username;
}
static key = 'Stream';
}

const getStream = new RestEndpoint({
urlPrefix: 'https://mystreamsite.tv',
path: '/:username',
schema: Stream,
process(value, { username }) {
value.username = username;
return value;
},
});

行情价格​

下面是一个真实的 API 示例,其行情数据中不包含主键 product_id。

我们使用 RestEndpoint.process() 从参数中取值,添加 product_id 成员。

import { Entity, RestEndpoint } from '@data-client/rest';
import { Temporal } from 'temporal-polyfill';

export class Ticker extends Entity {
  product_id = '';
  trade_id = 0;
  price = 0;
  size = '0';
  time = Temporal.Instant.fromEpochMilliseconds(0);
  bid = '0';
  ask = '0';
  volume = '';

  pk(): string {
    return this.product_id;
  }
  static key = 'Ticker';

  static schema = {
    price: Number,
    time: Temporal.Instant.from,
  };
}

export const getTicker = new RestEndpoint({
  urlPrefix: 'https://api.exchange.coinbase.com',
  path: '/products/:productId/ticker',
  schema: Ticker,
  process(value, { productId }) {
    value.product_id = productId;
    return value;
  },
  pollFrequency: 2000,
});
结果
Store▶

使用 HTTP 头​

HTTP Headers 可以在 fetch 的 Response 中访问。RestEndpoint.fetchResponse() 可用于构建 RestEndpoint。

这有时用于基于游标的分页。

import { RestEndpoint, RestGenerics } from '@data-client/rest';

class GithubEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
async parseResponse(response: Response) {
const results = await super.parseResponse(response);
if (
(response.headers && response.headers.has('link')) ||
Array.isArray(results)
) {
return {
link: response.headers.get('link'),
results,
};
}
return results;
}
}

文件下载​

对于返回二进制数据(文件、图片、PDF)的 endpoint,请设置 content: 'blob'。其返回类型为 Blob, schema 默认为 undefined(二进制数据无法规范化)。使用 dataExpiryLength: 0 可以避免在内存中缓存大型 blob。

downloadFile.ts
import { RestEndpoint } from '@data-client/rest';

export const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
});
DownloadButton.tsx
import { useController } from '@data-client/react';
import { downloadFile } from './downloadFile';

function DownloadButton({ id }: { id: string }) {
const ctrl = useController();

const handleDownload = async () => {
const blob: Blob = await ctrl.fetch(downloadFile, { id });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'download';
a.click();
URL.revokeObjectURL(url);
};

return <button onClick={handleDownload}>Download</button>;
}

要从 Content-Disposition 头中提取文件名,请覆盖 parseResponse:

downloadFile.ts
import { RestEndpoint } from '@data-client/rest';

export const downloadFile = new RestEndpoint({
path: '/files/:id/download',
content: 'blob',
dataExpiryLength: 0,
async parseResponse(response) {
const blob = await response.blob();
const disposition = response.headers.get('Content-Disposition');
const filename =
disposition?.match(/filename="?(.+?)"?$/)?.[1] ?? 'download';
return { blob, filename };
},
process(value): { blob: Blob; filename: string } {
return value;
},
});

对于 ArrayBuffer 响应(适合在内存中处理二进制数据),以同样的方式使用 content: 'arrayBuffer'。

重命名键​

有时 API 可能会更改某个键名,或者选用了你不喜欢的名字。当然,你有更好的命名规范,因此你不想改动 Resource 类的定义以及所有代码,而只想重新映射这个键。

ArticleResource.ts
class RenamedEndpoint<
O extends RestGenerics = any,
> extends RestEndpoint<O> {
getRequestInit(body) {
if (body && 'carrotsUsed' in body) {
const newBody = {
...body,
carrotsUSedIsThisNameTooLong: carrotsUsed,
};
delete newBody.carrotsUsed;
return super.getRequestInit(newBody);
}
return super.getRequestInit(body);
}
process(value) {
if ('carrotsUsedIsThisNameTooLong' in value) {
// ok to mutate jsonResponse since we control it
value.carrotsUsed = value.carrotsUsedIsThisNameTooLong;
delete value.carrotsUsedIsThisNameTooLong;
}
return value;
}
}