@i.un/openapi-kit
v0.1.1
Published
Framework-agnostic, zero-runtime type toolkit that binds an openapi-typescript `paths` definition to typed API client signatures.
Maintainers
Readme
@i.un/openapi-kit
框架无关、零运行时、零依赖的纯类型工具集 —— 把 openapi-typescript 生成的 paths 定义,绑定成 typed API client 的签名。给定一对 (path, method),推导出 query / body / path 参数类型与解包后的响应类型。整个包只导出 type,打包后近乎为空。
Framework-agnostic, zero-runtime, zero-dependency type toolkit that binds an openapi-typescript paths definition to typed API client signatures. Given a (path, method) pair it derives the query / body / path parameter types and the unwrapped response type. The whole package only exports types, so it adds nothing to your bundle.
它是 @i.un/nuxt-api 和 @i.un/api-client 背后共享的类型内核。
It's the shared type core behind @i.un/nuxt-api and @i.un/api-client.
安装 / Install
npm install -D @i.un/openapi-kit # 纯类型,放 devDependencies / types-only, dev dep
# pnpm add -D @i.un/openapi-kit先用 openapi-typescript 生成 paths:
First generate paths with openapi-typescript:
pnpm dlx openapi-typescript http://localhost:3000/api/openapi.json -o types/openapi.ts快速上手 / Quick start
最省事的用法:ApiKit 把整张 spec 折叠成索引映射,三常量绑一次,之后按 path → method → 槽位 取类型。
The easiest path: ApiKit folds the whole spec into an index map — bind the three constants once, then read types via path → method → slot.
import type { paths } from '~/types/openapi';
import type { ApiKit } from '@i.un/openapi-kit';
type Api = ApiKit<paths, '/api', 'data'>;
type UserBalance = Api['/me/credits']['get']['response']; // 解包后的 data
type CreateRecharge = Api['/me/credits/recharges']['post']['body'];
type ListQuery = Api['/me/credits/queries']['get']['query'];
type SessionPath = Api['/me/sessions/{id}']['delete']['path']; // { id: string }path 与 method 都有自动补全;response 已按 DataKey 解包信封。零别名样板。
Path and method autocomplete; response is already envelope-unwrapped by DataKey. No per-alias boilerplate.
泛型常量 / Generics
由消费方一次性绑定的四个常量: Four constants, bound once per project by the consuming client:
| 泛型 / Generic | 含义 / Meaning | 默认 / Default |
| --- | --- | --- |
| Paths | openapi-typescript 的 paths;unknown = 未类型化(legacy) / the paths; unknown = untyped | unknown |
| Prefix | 从 spec 路径剥掉的前缀(如 NestJS /api)/ prefix stripped from spec keys | '' |
| DataKey | 从 200 响应信封解包的字段名;never = 不解包 / envelope field to unwrap; never = no unwrap | 'data' |
| RequestOptions | transport 选项类型(仅 client 层)/ transport options type (client layer only) | unknown |
RequestOptions 是唯一的"可插拔"轴 —— 让 client 透传 headers / timeout / signal 等底层选项,而 kit 本身不依赖任何 fetch 实现。
RequestOptions is the only "pluggable" axis: it lets a client pass through transport options without the kit depending on any fetch implementation.
import type { ApiMethod } from '@i.un/openapi-kit';
import type { FetchOptions } from 'ofetch';
type Get = ApiMethod<paths, '/api', 'data', 'get', FetchOptions>; // ofetch
type GetNative = ApiMethod<paths, '/api', 'data', 'get', RequestInit>; // 原生 fetch
type GetBare = ApiMethod<paths, '/api', 'data', 'get'>; // 无透传 / no passthrough导出一览 / Exports
// 路径 / 方法 helpers — path / method helpers
StripPrefix, AllPaths, EndpointFor, OperationFor, MethodsFor, DefaultMethod
// 参数 / 请求体槽位 — parameter & request-body slots
QueryFor, PathParamsFor, HeaderParamsFor, BodyFor, RequestContentFor
// 响应 — response
ResponseFor, // 200 + application/json + 按 DataKey 解包(高层 / high level)
ResponseContentFor // 任意 status + 任意 media,不解包(低层 / low level)
// 便捷映射 — convenience map
ApiKit
// 客户端层(带 RequestOptions)— client layer (RequestOptions-parameterized)
CallOptions, RequiredPathCallOptions, OptionsArgsFor, ApiMethod| 类型 / Type | 给定 / Given | 产出 / Yields |
| --- | --- | --- |
| AllPaths | — | 所有 path 键并集(去前缀)/ union of all path keys |
| MethodsFor | path | 该 path 真实支持的方法(排除 never)/ methods a path actually supports |
| DefaultMethod | path [, fallback] | 智能默认方法 / smart default method |
| QueryFor | path + method | parameters.query |
| PathParamsFor | path + method | parameters.path |
| HeaderParamsFor | path + method | parameters.header |
| BodyFor | path + method | JSON 请求体 / JSON request body |
| RequestContentFor | + media type | 任意 media 的请求体 / request body for any media |
| ResponseFor | path + method | 200 JSON,按 DataKey 解包 / 200 JSON, unwrapped |
| ResponseContentFor | + status + media | 任意状态/媒体的裸响应(错误码、csv …)/ raw body for any status/media |
| ApiKit | — | path → method → { response, body, query, path, header } 映射 / index map |
两种消费风格 / Two consuming styles
1. ApiKit 索引访问(推荐,零样板)/ Indexed access (recommended)
见上面快速上手。绑一次 type Api = ApiKit<...>,之后全是 Api['/x']['get']['response']。
See Quick start. Bind once, then everything is Api['/x']['get']['response'].
2. 具名别名(若你偏好 ApiResponse<'/x'>)/ Named aliases
用 DefaultMethod 把"默认 GET、否则该 path 的方法"那段逻辑藏起来,每个别名保持一行:
DefaultMethod hides the "default to GET, else the path's methods" logic, keeping each alias a one-liner:
import type {
ResponseFor, BodyFor, AllPaths, MethodsFor, DefaultMethod,
} from '@i.un/openapi-kit';
export type ApiResponse<
P extends AllPaths<paths, '/api'>,
M extends MethodsFor<paths, '/api', P> = DefaultMethod<paths, '/api', P>,
> = ResponseFor<paths, '/api', P, M, unknown, 'data'>;
export type ApiBody<
P extends AllPaths<paths, '/api'>,
M extends MethodsFor<paths, '/api', P> = DefaultMethod<paths, '/api', P, 'post'>,
> = BodyFor<paths, '/api', P, M>;怎么选 / Which one — 想保留
ApiResponse<'/x'>别名、或 spec 很大(几百端点),用风格 2(轻、按需算)。想彻底不写别名,用风格 1(ApiKit)。别两个都上(既建大映射又包别名)。 KeepApiResponse<'/x'>aliases or have a huge spec → style 2 (lean). Want no aliases at all → style 1 (ApiKit). Don't do both.
适配你的后端 / Adapting to your backend
ResponseFor(及 ApiKit 的 response 槽)编码了一个约定:200 + application/json + 按 DataKey 解包信封。不符合约定也能用,有逃生口:
ResponseFor (and ApiKit's response slot) encode a convention: 200 + application/json + envelope-unwrap by DataKey. Off-convention backends are still supported via escape hatches:
后端没有信封 / No envelope
如果后端直接返裸 JSON(没有 { code, message, data } 包一层),把 DataKey 设成 never —— response 就是完整的 200 body,原样不解包:
If your backend returns raw JSON (no { code, message, data } wrapper), set DataKey = never — response is the full 200 body, unwrapped untouched:
type Api = ApiKit<paths, '', never>; // DataKey = never
type UserList = Api['/users']['get']['response']; // 完整 200 body / full 200 body信封字段名不同 / Different envelope key
type Api = ApiKit<paths, '', 'payload'>; // 解包 payload 而非 data / unwrap `payload`信封 / 非信封混用 / Mixed envelope & non-envelope
即使 DataKey = 'data',ResponseFor 也会先用 keyof 守卫确认该响应确实有 data 键,否则原样返回。所以同一个 API 里信封端点和非信封端点(如 /health 返 {status:'ok'})混着也精确。
Even with DataKey = 'data', ResponseFor first checks (via a keyof guard) that the response actually has a data key, otherwise returns it as-is. So an API can mix envelope and non-envelope endpoints (e.g. /health returning {status:'ok'}) and stay precise.
非 200 状态码 / 非 JSON 响应 / Non-200 status or non-JSON
ResponseFor 只看 200 + application/json。如果你的端点用 201(Created)、204、或返回 text/csv / application/x-protobuf,用低层的 ResponseContentFor(status 与 media 都可指定,不解包):
ResponseFor only looks at 200 + application/json. For endpoints using 201 / 204, or returning text/csv / application/x-protobuf, use the low-level ResponseContentFor (status and media are parameters, no unwrap):
import type { ResponseContentFor } from '@i.un/openapi-kit';
type Created = ResponseContentFor<paths, '/api', '/users', 'post', 201>;
type Csv = ResponseContentFor<paths, '/api', '/export', 'get', 200, 'text/csv'>;
type Error4xx = ResponseContentFor<paths, '/api', '/users', 'get', 400>;设计上故意把"约定层"(
ResponseFor,解包)和"裸层"(ResponseContentFor,任意 status/media)分开 —— 解包只对 JSON 信封有意义,二者不该揉在一起。 The "convention layer" (ResponseFor, unwrapping) and the "raw layer" (ResponseContentFor, any status/media) are deliberately separate — unwrapping only makes sense for JSON envelopes.
自建类型 kit / Build a project type kit
不想直接用库原语?在项目里组装一组短别名(只引一次 paths / Prefix / DataKey):
Prefer your own short aliases? Compose them once per project:
import type { paths } from '~/types/openapi';
import type { QueryFor, AllPaths, MethodsFor, DefaultMethod } from '@i.un/openapi-kit';
export type ApiQuery<
P extends AllPaths<paths, '/api'>,
M extends MethodsFor<paths, '/api', P> = DefaultMethod<paths, '/api', P>,
> = QueryFor<paths, '/api', P, M>;包一层 client / Build a client wrapper
@i.un/nuxt-api / @i.un/api-client 用 ApiMethod + RequestOptions 声明返回的方法形态。每个包只在一处绑定自己的 transport 类型:
@i.un/nuxt-api / @i.un/api-client use ApiMethod + RequestOptions to declare the returned method shape. Each package binds its transport type in one place:
import type { ApiMethod } from '@i.un/openapi-kit';
import type { FetchOptions } from 'ofetch';
// 在每个方法处,把 transport 类型(这里是 ofetch 的 FetchOptions)传给 ApiMethod 的第 5 个泛型;
// 无需在本地再造别名 —— 直接用 kit 的 ApiMethod。
// pass your transport type (here ofetch's FetchOptions) as ApiMethod's 5th generic at each
// method — no local alias needed; use the kit's ApiMethod directly.
const useApi = <Paths = unknown, Prefix extends string = '', DataKey extends string = 'data'>() => ({
get: _get as ApiMethod<Paths, Prefix, DataKey, 'get', FetchOptions>,
post: _post as ApiMethod<Paths, Prefix, DataKey, 'post', FetchOptions>,
// put / patch / del …
});RequestOptions = unknown 时 CallOptions 里 Omit<unknown, …> 退化成 {},即零透传;原生 fetch 绑 RequestInit 即可。
With RequestOptions = unknown, CallOptions's Omit<unknown, …> collapses to {} (no passthrough); for native fetch bind RequestInit.
设计说明 / Design notes
- 零运行时、零依赖 —— 只导出
type,dist 的 JS 近乎为空(~33B)。 Zero runtime, zero deps — onlytypes; the emitted JS is ~33 bytes. Paths = unknown(未类型化) —— endpoint 接受任意 string、响应由<T>指定,完全兼容无 spec 的旧调用。Paths = unknown— endpoint accepts any string, response from<T>; fully compatible with spec-less legacy calls.Prefix/DataKey不做运行时校验 —— 它们是类型层约定,需与你 client 的运行时配置(baseURL 前缀、解包字段)保持一致。Prefix/DataKeyaren't runtime-checked — they're type-level conventions that must match your client's runtime config.
许可证 / License
MIT
