socar-api-client
v0.1.3
Published
SoCar V4 API Client — unified data access layer for socar-data-platform and socar-manager-platform
Readme
socar-api-client
JSON:API + OpenAPI schema 运行时驱动 — 零手写 CRUD。
符合 JSON:API 规范的 OpenAPI schema 驱动客户端。schema 在哪里,filter 映射、URL 路由、 关系发现、权限判定就在哪里——不写胶水代码、不手写 service、不维护重复的 filterMapper。
import { ApiClient } from 'socar-api-client'
await ApiClient.init({ schema: '/path/to/schema.json', repository, guarder })
// 一个 resource 对应一个 JSON:API type,直接 CRUD
const sales = ApiClient.resource('sales')
const { data } = await sales.find({ filter: { time: '2024-01' } })安装
pnpm add socar-api-client模块总览
| 模块 | 导出 | 干什么 | 什么时候用 |
|------|------|-------|-----------|
| ApiClient | ApiClient | 静态外观。管理初始化、resource 工厂、权限查询、filter 格式 | 应用启动时 init,之后全局通过 ApiClient.resource\<T\>(type) 获取 resource |
| ApiResource | ApiResource, JsonApiResponse | 泛型 CRUD。find / findById / create / update / delete + enrich 管道 | 拿到 resource 后直接用,不写 service |
| SchemaRegistry | SchemaRegistry | 运行时加载 schema.json,自动推导 filter mapper、URL、relationships、protected 资源 | ApiClient.init 内部使用,也可独立用于 codegen 或检测 |
| ServiceBase | ServiceBase | filter 处理引擎 + CRUD 委托基类。内部门面,不直接使用 | 旧迁移场景的基类,新代码通过 ApiResource |
| Permission | Permission, PermissionError | 资源级权限判定。inject / can / isRoot,匹配规则:精确 > type.* > . | ApiClient 内部使用,也可独立使用 |
| Enricher | Enricher, EnricherRegistry, enricherRegistry | 响应数据后处理管道。注册 enricher 后 ApiResource 在 { enrich: true } 时自动调用 | 需要给 API 响应加派生字段、格式化、裁剪时 |
| SoCarDataLayer | SoCarDataLayer, Repository, supplier, scope, orbit, socarSource | 源码打包的 HTTP 客户端。axios + 多源管理 + token + scope 查询 + 新旧 filter 格式 | 初始化时创建 Repository,内部使用 |
| Codegen(子路径) | generate, check | 根据 OpenAPI schema 生成 schema.json / types.d.ts / resources.ts | CLI 或 CI 中调用,产物给 SchemaRegistry 消费 |
架构
ApiClient → 静态外观,管理生命周期
├─ SchemaRegistry → 加载 schema,推导 filter / URL / 关系 / 权限
├─ Permission → 权限判定引擎
└─ Repository → SoCarDataLayer 封装
ApiResource<T> → 泛型 CRUD,消费方唯一直接使用的接口
├─ ServiceBase → filter 处理 + 方法委托
└─ Enricher → 响应后处理(可选)
SoCarDataLayer → HTTP 客户端(axios),多源管理
Codegen → 构建时工具,产出 schema 运行时文件快速开始
import { ApiClient, SoCarDataLayer, Repository } from 'socar-api-client'
// 初始化
const repository = new Repository(SoCarDataLayer)
await ApiClient.init({
schema: '/path/to/schema.json', // URL 或本地路径
repository,
guarder: ({ method, type }) => ({ scope: ApiClient.can(type, method) }),
permissions: ['*.*'],
filterFormat: 'standard', // 'standard'(新后端) | 'legacy'(旧后端)
})
// CRUD — 零手写 service、零 local filterMapper
const sales = ApiClient.resource('sales')
const { data: list } = await sales.find({
filter: { time: '2024-01', group: ['bmw', 'audi'] },
include: ['dealer'],
page: { limit: 20 },
})
const { data: item } = await sales.findById('123')
const { data: created } = await sales.create({ name: '新车' })
const { data: updated } = await sales.update('123', { price: 110000 })
await sales.delete('123')数据流
请求 → ApiResource → ServiceBase.filterHandler → SoCarDataLayer → 后端
↑ ↑
SchemaRegistry filter 自动映射API 参考
ApiClient
// 初始化(全部求值后一次性赋值,中间抛异常不污染旧状态)
ApiClient.init(config: {
schema: string | object // schema.json URL / 本地路径 / 已解析对象
repository: unknown // 平台 Repository 实例
guarder: (params: { method: string; type: string }) => { scope?: boolean; url?: string }
permissions?: string[] // 初始权限列表,['*.*'] 放行全部
filterFormat?: 'standard' | 'legacy' // 默认 'standard' — 新后端结构化 filter 格式
}): Promise<void>
ApiClient.resource<T>(type: string): ApiResource<T>
// 权限
ApiClient.can(type: string, method?: string): boolean
ApiClient.injectPermissions(perms: string[]): void // 运行时更新(角色切换 / token refresh)
ApiClient.getPermissionEntries(): string[] // 读取当前权限列表ApiResource
interface ApiResource<T> {
find<U>(options?: {
filter?: Record<string, any> // 原始 filter 参数 → SchemaRegistry 自动映射
include?: string[] // JSON:API include
page?: { limit?: number; offset?: number }
sort?: string
fields?: string[]
enrich?: boolean // 走 enricher 管道
}): Promise<JsonApiResponse<U>>
findById<U>(id: string, options?: {
include?: string[]
enrich?: boolean
}): Promise<JsonApiResponse<U>>
create<U>(data: Partial<T>, options?: {
include?: string[]
}): Promise<JsonApiResponse<U>>
update<U>(id: string, data: Partial<T>): Promise<JsonApiResponse<U>>
delete(id: string): Promise<void>
}
// 全部返回信封:
// { data: U, included?: RelatedResource[], meta?: Record<string, any>, links?: Record<string, string> }SchemaRegistry
const registry = await SchemaRegistry.load('/path/to/schema.json')
// 也支持已解析对象
const registry = await SchemaRegistry.load(parsedObject)
registry.getFilterMapper('sales')
// → { time: { op: 'bt' }, group: { op: 'aeq', key: 'group.id' } }
registry.getResourceUrl('sales') // → '/sales'
registry.getResourceUrlMap() // → { sales: '/sales', ... }
registry.getRelationships('sales') // → { dealer: { type, kind }, ... }
registry.getProtectedResources() // → Set<'sales', 'price', ...>默认操作符推导(D10):
| valueType | 默认 op | 说明 |
|-----------|---------|------|
| string | eq | 等值 |
| integer / number | eq | 等值 |
| boolean | eq | 等值 |
| datetime | bt | 范围 |
| list | ct | 包含 |
如果默认 op 不在 OpenAPI 声明的 valid operators 中,自动 fallback 到第一个 valid op,并输出警告。
Permission
const perm = Permission.fromSchemaRegistry(schemaRegistry)
perm.inject(['sales.get', 'price.*']) // 注入权限
perm.can('sales', 'get') // true / false
perm.isRoot // 是否持有 *.*匹配优先级:精确匹配 > type.* > *.* > 未保护资源默认允许。
FilterHandler (ServiceBase)
filter 参数全部通过 filterHandler 处理,自动映射字段名和操作符,输出结构化 filter[field][op]=value 格式。
service.filterHandler({ status: 0, name: 'test', time: undefined, group: null }, mapper)
// → { __socar__filter__: { status: { op: 'eq', value: 0 }, name: { op: 'ct', value: 'test' } } }
// time(undefined) 和 group(null) 被跳过0/false/''作为有效过滤值处理(不再被if (val)跳过)null/undefined跳过- 对象值
{ op, value }作为标准 filter 直接透传 - 未在 mapper 中定义的字段在开发环境输出
console.warn
OR 过滤:传入 __socar_or_filter__ 键,自动合并为 AND 语义并输出 console.warn 提示(新后端不支持 OR)。
关系操作
await service.updateRelationship({ id: '1', type: 'user' }, { key: 'roles', data: [{ id: '2' }] })
await service.removeRelationship({ id: '1', type: 'user' }, { key: 'roles', data: [{ id: '2' }] })
await service.createRelationship({ id: '1', type: 'user' }, { key: 'roles', data: [{ id: '2' }] })三个方法统一校验:
identity— 非空,含id和typerelationshipData— 非 Array,key为非空 string- 校验始终开启(不依赖
NODE_ENV)
Enricher
abstract class Enricher<TInput, TOutput = TInput> {
abstract enrich(input: TInput): TOutput // 必须覆写
enrichMany(inputs: TInput[]): TOutput[] // 默认委托给 enrich
}注册到全局 enricherRegistry,ApiResource 在 { enrich: true } 时自动调用:
class HelpEnricher extends Enricher<HelpAttrs, HelpDisplay> {
enrich(input: HelpAttrs): HelpDisplay {
return { ...input, excerpt: input.content.slice(0, 80) }
}
}
enricherRegistry.register('help', new HelpEnricher())
const { data } = await helpApi.find({ enrich: true })SoCarDataLayer
源码打包,零外部依赖。支持:
- JSON:API HTTP 客户端(axios-based)
- 多数据源管理(remote / memory / orbit)
- 新旧后端 filter 格式切换(
filterFormat) - Token 管理
- Scope 查询
- 延迟初始化
import { SoCarDataLayer, supplier, scope, orbit, socarSource } from 'socar-api-client'Codegen
位于独立子路径 socar-api-client/codegen,不包含在主入口中(避免 Node 依赖泄露到浏览器 bundle):
import { generate, check } from 'socar-api-client/codegen'
// 或通过 CLI
// npx socar-codegen ...# 根据 OpenAPI schema 生成 3 个文件
npx socar-codegen --url https://data.dash.so.car/ --out ./src/__generated__
# 对比 committed 版本,确认 schema 未过期
npx socar-codegen --url https://data.dash.so.car/ --out ./src/__generated__ --check
# 冒烟测试
pnpm codegen:check| 文件 | 用途 | 替代旧产物 |
|------|------|-----------|
| schema.json | 运行时 SchemaRegistry 消费 | filterMapper.ts / scope.json / ResourceUrlMap |
| types.d.ts | 编译时类型接口 | openapi-types.ts |
| resources.ts | ResourceType 枚举常量 | ResourceType(仅保留枚举) |
错误处理
import { PermissionError } from 'socar-api-client'
try {
await sales.create(data)
} catch (err) {
if (err instanceof PermissionError) {
// statusCode = 403 (Forbidden) — 已认证但无权限
// 使用 instanceof 判断,不要检查 statusCode
}
}JSON:API
本库严格遵循 JSON:API 规范,请求和响应均使用 application/vnd.api+json。
响应信封
所有 CRUD 方法返回统一信封:
interface JsonApiResponse<T> {
data: T // 单体或集合
included?: RelatedResource[] // include 查询的关联资源
meta?: Record<string, any> // 分页、聚合等元信息
links?: Record<string, string> // 分页链接(self / next / prev / first / last)
}请求体(create / update)
// 库内部自动包装为 JSON:API 格式,你传原始数据即可
sales.create({ name: '新车', price: 110000 })
// 实际发送: { data: { type: 'sales', attributes: { name: '新车', price: 110000 } } }
sales.update('123', { price: 120000 })
// 实际发送: { data: { type: 'sales', id: '123', attributes: { price: 120000 } } }Resource Identifier
关系操作和权限判定使用统一标识格式:
type ResourceIdentifier = { id: string; type: string }过滤参数
OpenAPI 声明式驱动,输出结构化 filter 参数:
原始: { time: '2024-01', group: ['bmw', 'audi'] }
实际发送: filter[time][bt]=2024-01&filter[group.id][aeq]=bmw,audi- 新旧后端通过
filterFormat切换('standard'|'legacy') - 新后端(标准):结构化
filter[field][op]=value - 旧后端(legacy):Orbit 编码
filter=<encoded>
迁移
旧 extends ServiceBase 的 service 和新 ApiResource 可以共存,逐模块迁移即可。
开发
pnpm install
pnpm build # tsc 编译
pnpm typecheck # tsc --noEmit
pnpm test # vitest run(140+ 测试)
pnpm test:watch # vitest 监听模式
pnpm codegen:check # codegen 冒烟测试
pnpm clean # 清空 distLicense
Proprietary
