跳转到内容

HTTP

HTTP 用配置树声明业务接口,并统一处理 baseURL、headers、timeout、retry、拦截器、错误归一化和响应解包。配置本身就是契约,不需要再维护一份独立的 API 类型树。

基础使用

ts
import { createHttp } from '@freenotes/web-runtime';

interface Book {
  id: string;
  name: string;
  price: number;
}

interface BookInput {
  name: string;
  price: number;
}

const http = createHttp({
  baseURL: '/api',
  headers: {
    'client-source': 'web'
  },
  timeoutMs: 15000,
  retry: {
    maxRetries: 2,
    delayMs: 1000
  }
});

export const api = http.createApi((contract) => ({
  books: {
    get: {
      method: 'get',
      url: '/books/:id',
      path: contract.type<{ id: string }>(),
      query: contract.type<{ includeDeleted?: boolean }>(),
      response: contract.type<Book>()
    },
    create: {
      method: 'post',
      url: '/books',
      data: contract.type<BookInput>(),
      response: contract.type<Book>()
    }
  }
}));

method 会保留字符串字面量,不需要写 as constpathquerydataresponse 是编译期类型标记,不会参与请求序列化,也不会要求业务额外创建运行时 schema。

调用时会直接得到输入补全和返回值类型:

ts
const book = await api.books.get({
  path: { id: 'book-1' },
  query: { includeDeleted: false },
  options: { timeoutMs: 3000 }
});

await api.books.create({
  data: { name: 'TypeScript', price: 99 }
});

query 的所有字段都是可选字段时,整个 query 也可以省略;声明了必填 pathdata 时,调用参数会相应变为必填。即使未声明 path 契约,字面量 URL 中的 :name 也会被推导为必填 path 字段;显式声明 path 时,其 output 必须包含 URL 的全部占位符。完全未声明类型标记的配置仍可用于纯运行时场景,返回值为 unknown,其余调用字段降级为通用 HttpCallInput

使用 Zod 等 Schema

pathquerydataresponse 也可以直接接收实现 Standard Schema V1 的 schema。HTTP 不依赖 Zod;业务项目可以自行选择 Zod 或其他兼容库。

ts
import { z } from 'zod';
import { createHttp } from '@freenotes/web-runtime';

const http = createHttp({ baseURL: '/api' });

export const api = http.createApi({
  books: {
    get: {
      method: 'get',
      url: '/books/:id',
      path: z.object({
        id: z.string()
      }),
      query: z.object({
        includeDeleted: z.boolean().optional()
      }),
      response: z.object({
        id: z.string(),
        title: z.string()
      })
    },
    create: {
      method: 'post',
      url: '/books',
      data: z.object({
        title: z.string().trim(),
        price: z.coerce.number()
      }),
      response: z.object({
        id: z.string(),
        title: z.string(),
        price: z.number()
      })
    }
  }
});

Schema 的 input 类型决定调用参数,output 类型决定转换后的请求值或响应返回值。因此上例 price 可以接收 Zod coercion 支持的输入,实际发送前会转换为 number

当 schema 将 input 声明为 unknown 时,HTTP 不会据此假定该字段允许省略;业务仍需显式传入字段。只有 schema input 明确包含 undefined,或 query 的对象字段全部可选时,调用字段才会变为可选。

校验顺序如下:

  1. pathquerydata 在 URL 构造与 transformRequest 之前校验;
  2. schema 的 coercion/transform output 进入实际请求;
  3. responsetransformResponse、response interceptor 和业务 envelope 解包之后校验;
  4. 请求校验失败抛出 ERR_HTTP_REQUEST_VALIDATION,响应校验失败抛出 ERR_HTTP_RESPONSE_VALIDATION,具体问题保存在 HttpError.issues

如果只需要编译期类型、不希望引入运行时校验成本,继续使用 contract.type<T>()

请求输入

endpoint 方法统一接收一个对象:

ts
interface HttpCallInput {
  path?: Record<string, string | number | boolean>;
  query?: Readonly<Record<string, unknown>>;
  data?: unknown;
  options?: {
    baseURL?: string;
    headers?: Record<string, string>;
    decodeResponse?: HttpResponseDecoder | false;
    responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
    signal?: AbortSignal;
    timeoutMs?: number;
  };
}

path 替换 URL 中的 :name,并通过 encodeURIComponent 编码;query 映射为 URL 查询参数;data 作为 request body;options 只覆盖当前调用。HTTP 不根据 method 自动丢弃 querydata

全局配置、endpoint 配置、调用级 options 和 transformRequest 的结果在 request interceptor 之前合并。拦截器可以删除最终请求中的 header,例如 delete request.headers.Authorization;发送时不会重新补回 runtime 默认 header,也不会影响后续请求的默认配置。

响应与错误

HTTP 默认保留响应体,不解释普通 codeok 字段。需要业务 envelope 时,在 client 上显式配置:

ts
import { createHttp, unwrapBusinessEnvelope } from '@freenotes/web-runtime';
const http = createHttp({ baseURL: '/api', decodeResponse: unwrapBusinessEnvelope });

该预设识别以下格式并返回其中的 data

json
{
  "code": 0,
  "data": {},
  "message": "success"
}

0200"0""200" 表示成功;boolean ok 优先。失败抛出 kind: "business"HttpError,业务码保存在 businessCode。非 envelope 数据按原值返回。endpoint 或单次调用可用 decodeResponse: false 禁用 client 解包,也可提供自定义 decoder。

decodeResponse(response, context) 在 endpoint transform 和 response interceptor 之后、响应 schema 校验之前运行。它可以是异步函数。

HttpError.kind 区分 definitionrequest-validationresponse-validationtransportbusinessabortedprocessingcode 仅保存 runtime 错误码;transportCode 保存 axios 原始代码,如 ERR_CANCELEDECONNABORTEDERR_NETWORKbusinessCode 仅由显式业务协议 decoder 填充。HTTP 非 2xx 失败不自动解释响应体中的业务字段,原始 body 保留在 error.response.data

请求与响应转换

特殊协议可以使用 endpoint 的 transformRequesttransformResponse

ts
const transportApi = http.createApi((contract) => ({
  submit: {
    method: 'post',
    url: '/transport/:scope',
    path: contract.type<{ scope: string }>(),
    data: contract.type<{ token: string }>(),
    response: contract.type<{ ok: boolean }>(),
    transformRequest({ input }) {
      return {
        data: encryptPayload(input.data),
        headers: { 'Content-Type': 'application/octet-stream' },
        responseType: 'text'
      };
    },
    transformResponse(value) {
      return decryptPayload(String(value));
    }
  }
}));

转换函数只负责当前 endpoint。认证头、trace id、统一日志和错误上报等通用逻辑应放到 createHttp({ interceptors })

Retry

默认关闭重试(maxRetries: 0)。显式设置 maxRetries > 0 后,默认仅允许重试 getheadoptions,避免自动重放有副作用的写请求。maxRetries 不包含首次请求,例如 1 表示最多请求两次。

默认可重试 transport code 包括 ECONNABORTEDENETUNREACHENOTFOUNDERR_NETWORKETIMEDOUT。如写接口通过幂等键保证可安全重放,可显式配置:

ts
const http = createHttp({
  retry: {
    maxRetries: 1,
    retryableMethods: ['get', 'post'],
    retryableStatusCodes: [408, 500, 502, 503, 504]
  }
});

默认重试状态码为 408500502503504,初始间隔为 1000ms,退避倍数为 2,单次等待上限为 30000ms。只有方法及失败条件符合配置时才会重试,取消请求不会触发重试。

为什么没有 request

http.request() 不作为公开 API。业务请求集中通过 createApi() 声明,才能维持统一接口契约、类型补全和迁移边界。底层 axios 只是内部 adapter,不进入公开类型。

让基础能力保持简单,让业务开发更加专注。