工程化

Python 后端生成 TypeScript 类型契约实践

本文基于以下两个项目当前未提交代码整理:

  • /Users/lili/company/common/auth-svc
  • /Users/lili/company/ota/ota-admin-frontend

目标

auth-svc 新增一个基于 FastAPI OpenAPI schema 生成的 TypeScript 类型包,前端通过该类型包消费后端接口契约。

当前阶段只接入类型,不生成请求 client,也不改变 ota-admin-frontend 现有的 request 封装。

未提交变更概览

auth-svc

后端项目新增 OpenAPI 导出脚本与类型包目录。

scripts/export_openapi.py
contracts/auth-svc-types/README.md
contracts/auth-svc-types/package.json
contracts/auth-svc-types/pnpm-lock.yaml
contracts/auth-svc-types/openapi.json
contracts/auth-svc-types/index.d.ts
.gitignore

主要变化:

  1. 新增 scripts/export_openapi.py,通过 app.openapi() 导出后端 OpenAPI schema。
  2. 新增 contracts/auth-svc-types 类型包,包名为 @tripguru/auth-svc-types。
  3. 使用 openapi-typescript 将 openapi.json 生成 index.d.ts。
  4. .gitignore 增加 node_modules,避免类型包安装依赖后误提交。

当前生成的 OpenAPI 信息:

openapi: 3.1.0
title: auth-svc
version: 0.1.0

ota-admin-frontend

前端项目开始消费 auth-svc 生成的类型。

package.json
pnpm-lock.yaml
src/service/api/auth.ts
src/typings/api/auth-svc.d.ts

主要变化:

  1. package.json 增加 @tripguru/auth-svc-types 依赖,当前使用本地 link: 指向 auth-svc 的类型包目录。
  2. src/typings/api/auth-svc.d.ts 新增 Api.AuthSvc 命名空间,用于封装 OpenAPI 生成类型。
  3. src/service/api/auth.ts 引入 components,并将 fetchLogin 的请求参数和返回值替换为后端生成类型。
  4. 目前只有登录接口完成了第一步类型接入,其它 auth 相关接口仍主要使用原有 Api.Auth 手写类型。

后端类型包工作流

第一步:导出 OpenAPI schema

在 auth-svc 项目根目录执行:

uv run python scripts/export_openapi.py

执行后生成:

contracts/auth-svc-types/openapi.json

该文件来自 FastAPI 的 app.openapi(),是类型生成的契约源。

脚本内容:

import json
from pathlib import Path

from app.main import app


def main() -> None:
    output_path = Path('contracts/auth-svc-types/openapi.json')
    output_path.parent.mkdir(parents=True, exist_ok=True)
    output_path.write_text(
        json.dumps(app.openapi(), ensure_ascii=False, indent=2),
        encoding='utf-8',
    )


if __name__ == '__main__':
    main()

第二步:安装类型生成依赖

进入类型包目录:

cd contracts/auth-svc-types
pnpm install

当前类型包使用:

openapi-typescript: ^7.10.1

第三步:生成 TypeScript 声明文件

pnpm build

执行后生成:

contracts/auth-svc-types/index.d.ts

index.d.ts 是自动生成文件,不应手动修改。

第四步:检查发布内容

npm pack --dry-run

期望发布内容保持精简:

README.md
index.d.ts
openapi.json
package.json

前端接入方式

当前 ota-admin-frontend 使用本地 link 联调:

{
  "@tripguru/auth-svc-types": "link:/Users/lili/company/common/auth-svc/contracts/auth-svc-types"
}

正式发布后应改为 npm 包版本:

pnpm add -D @tripguru/auth-svc-types

建议前端不要在业务代码里到处直接书写 components['schemas']['xxx'],而是先封装业务别名。

当前新增的封装示例:

import type { components, operations, paths } from '@tripguru/auth-svc-types';

declare namespace Api {
  namespace AuthSvc {
    type Schemas = components['schemas'];
    type Operations = operations;
    type Paths = paths;

    type LoginRequest = Schemas['LoginRequest'];
    type TokenResponse = Schemas['TokenResponse'];
    type UserInfoResponse = Schemas['UserInfoResponse'];
    type RefreshTokenRequest = Schemas['RefreshTokenRequest'];
    type VerificationCodeSendRequest = Schemas['VerificationCodeSendRequest'];
  }
}

登录接口当前接入方式

fetchLogin 已从原来的手写类型切换到 OpenAPI 生成类型:

import type { components } from '@tripguru/auth-svc-types';

type LoginRequest = components['schemas']['LoginRequest'];
type LoginResponse = components['schemas']['TokenResponse'];

export function fetchLogin(params: LoginRequest) {
  return request<LoginResponse>({
    baseURL: getEnv('VITE_SERVICE_BASE_URL'),
    url: '/v1/auth/login',
    method: 'post',
    params: { loginScene: 'tenant_admin' },
    data: params
  });
}

这里返回值使用 TokenResponse,不是 ResponseModel_TokenResponse_。

原因是 ota-admin-frontend 的 request 封装已经在 transform 中执行了:

return response.data.data;

也就是说业务接口拿到的是后端 ResponseModel<T> 里的 data 字段,所以泛型应该传入内层业务数据类型 T。

当前契约字段示例

后端生成的登录请求类型:

type LoginRequest = {
  email: string;
  password: string;
  deviceId?: string | null;
  ip?: string | null;
  userAgent?: string | null;
};

后端生成的 token 响应类型:

type TokenResponse = {
  accessToken: string;
  expiresIn: number;
  refreshToken: string;
  refreshExpiresIn: number;
  tokenType: 'Bearer';
  tenantId?: number | null;
  userId: number;
  identityType: string;
  roles?: string[];
  scope?: string[];
};

后端接口约束

为了保证生成出来的前端类型准确,auth-svc 后续新增或修改接口时需要遵守:

  1. 请求体使用 Pydantic model。
  2. 复杂 Query 参数使用 Query model。
  3. 返回值声明为 ResponseModel[T]。
  4. 不在接口层返回裸 dict。
  5. 对外字段保持 camelCase,由后端 schema 统一控制。
  6. 接口变更后重新生成 openapi.json 和 index.d.ts。

后续建议

  1. 将 fetchGoogleLogin、fetchGoogleBind、fetchRefreshToken、sendAgentOnboardingEmailCode 等 auth 接口继续迁移到 Api.AuthSvc 类型。
  2. 当前 @tripguru/auth-svc-types 仍是本地 link:,正式环境应改为发布后的 npm 版本。
  3. 如果类型包需要发布,确认 contracts/auth-svc-types/package.json 的 private 字段是否应调整。
  4. CI/CD 中建议固定流程:导出 OpenAPI、安装依赖、生成声明文件、检查包内容、发布类型包。
  5. 前端升级类型包后,至少执行一次 typecheck,确保后端契约变更不会悄悄破坏调用方。