From cd01e135a16de50c0926d2f1d1685b874b9297c4 Mon Sep 17 00:00:00 2001 From: Cheng Jinbao Date: Wed, 12 Aug 2026 13:09:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=AE=9E=E7=8E=B0=E6=8E=A7?= =?UTF-8?q?=E5=88=B6=E9=9D=A2/=E6=95=B0=E6=8D=AE=E9=9D=A2=E5=88=86?= =?UTF-8?q?=E7=A6=BB=E7=9A=84=E5=8F=8C=E5=B1=82API=E6=9E=B6=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入open/admin双surface认证体系,open surface支持open scope API Key和匿名访问 - 新增OpenDataResource和OpenFunctionResource提供面向终端用户的开放API端点 - 修改Nginx配置实现子域名路由到页面和开放API,主域名路由到管理API - 更新API Key生成逻辑,区分admin/open作用域并调整前缀格式 - 重构认证过滤器实现基于路径的API surface隔离和权限校验 - 调整边缘函数调用路径从/functions/{projectId}/{name}改为/open/{projectId}/functions/{name} - 更新相关测试用例和依赖配置以适配新的API架构设计 --- typescript/README.md | 460 ++++++++++++++++-------- typescript/package.json | 2 +- typescript/src/admin-namespaces.ts | 170 +++++++++ typescript/src/client.ts | 240 ++++++++++--- typescript/src/data-namespace.ts | 9 +- typescript/src/functions-namespace.ts | 49 +++ typescript/src/graphql-namespace.ts | 64 ++++ typescript/src/http.ts | 53 +-- typescript/src/index.ts | 30 +- typescript/src/model-handle.ts | 16 +- typescript/src/query-builder.ts | 9 +- typescript/src/storage-namespace.ts | 145 ++++++++ typescript/src/types.ts | 2 + typescript/tests/client.test.ts | 141 +++++++- typescript/tests/data-namespace.test.ts | 2 +- typescript/tests/model-handle.test.ts | 4 +- typescript/tests/query-builder.test.ts | 4 +- 17 files changed, 1144 insertions(+), 256 deletions(-) create mode 100644 typescript/src/admin-namespaces.ts create mode 100644 typescript/src/functions-namespace.ts create mode 100644 typescript/src/graphql-namespace.ts create mode 100644 typescript/src/storage-namespace.ts diff --git a/typescript/README.md b/typescript/README.md index e14d768..5f5db1f 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -1,6 +1,18 @@ # @flexmodel/sdk -Flexmodel TypeScript SDK — 模型命名空间 + 选项对象 API,通过 `client.data` 对数据层进行 CRUD 操作,链式构建器用于复杂查询场景。 +Flexmodel TypeScript SDK — 面向终端用户(Open API)和管理后台(Admin API)的统一客户端,提供数据 CRUD、边缘函数调用、对象存储、GraphQL +以及项目管理能力。 + +## 架构概览 + +SDK 分为两个客户端,对应后端两套 API surface: + +| 客户端 | API surface | 路径前缀 | 认证方式 | 用途 | +|------------------------|-------------|-----------------------------|--------------------------------------------|-----------------------------------| +| `FlexmodelClient` | Open API | `/api/open/{projectId}` | open scope API Key、IdP Bearer token、匿名 | 终端用户:前端应用、边缘函数 | +| `FlexmodelAdminClient` | Admin API | `/api/projects/{projectId}` | admin scope API Key、系统 JWT | 管理后台:项目管理、用户、API Key | + +> 子域名路由模式下,Open client 路径不含 `/api/open/{projectId}` 前缀(projectId 从 Host 提取),详见 [路由模式](#路由模式)。 ## 安装 @@ -14,15 +26,17 @@ pnpm add @flexmodel/sdk ## 快速开始 +### Open API(终端用户) + ```typescript import { FlexmodelClient } from '@flexmodel/sdk' const client = new FlexmodelClient({ - apiKey: 'fm_ak_xxxxx', + apiKey: 'fm_ak_open_xxxxx', // open scope API Key projectId: 'my-project', }) -// 查询 +// 数据查询 const { list, total } = await client.data.from('Student').findMany({ where: { classId: { _eq: 1 }, age: { _gt: 15 } }, orderBy: 'name', @@ -30,80 +44,153 @@ const { list, total } = await client.data.from('Student').findMany({ size: 20, }) -// 获取单条 -const student = await client.data.from('Student').findOne('001', { expand: ['classId'] }) +// 调用边缘函数(直连 Deno Runtime) +const result = await client.functions.invoke('myFn', { key: 'value' }) -// 创建 -const created = await client.data.from('Student').create({ name: 'Alice', age: 16, classId: 1 }) +// 上传文件 +await client.storage.upload('my-bucket', 'photo.jpg', fileBlob) -// 批量创建 -const batch = await client.data.from('Student').createMany([ - { name: 'Alice', age: 16 }, - { name: 'Bob', age: 17 }, -]) +// GraphQL 查询 +const { data } = await client.graphql(`{ Student { id name } }`) +``` -// 更新(全量替换) -await client.data.from('Student').update(1, { data: { name: 'Alicia' } }) +### Admin API(管理后台) -// 批量更新(每条记录必须包含 id 字段) -await client.data.from('Student').updateMany({ data: [ - { id: 1, name: 'Alicia' }, - { id: 2, name: 'Bob Updated' }, -] }) +```typescript +import { FlexmodelAdminClient } from '@flexmodel/sdk' -// 更新(部分合并) -await client.data.from('Student').merge(1, { data: { name: 'Alicia' } }) +const admin = new FlexmodelAdminClient({ + apiKey: 'fm_ak_admin_xxxxx', // admin scope API Key + projectId: 'my-project', +}) -// 删除 -await client.data.from('Student').delete(1) +// 管理操作 +const projects = await admin.projects.list() +const users = await admin.users.list() +const apiKeys = await admin.apiKeys.list() -// 批量删除 -await client.data.from('Student').deleteMany({ ids: [1, 2, 3] }) +// 管理端数据 CRUD(admin 路径) +const students = await admin.data.from('Student').findMany() -// 计数 -const count = await client.data.from('Student').count({ where: { age: { _gt: 18 } } }) +// 部署边缘函数 +await admin.functions.deploy('my-project', 'myFn', { + sourceFiles: { 'index.ts': 'export default async (req) => req.json()' }, +}) ``` ## 认证 -V1 仅支持 **API Key** 认证: +### API Key(scope 区分) + +API Key 通过 `scope` 字段区分权限范围,格式为 `fm_ak_{scope}_{random}`: + +- `fm_ak_open_xxx` — open scope,可访问 Open API(终端用户场景) +- `fm_ak_admin_xxx` — admin scope,可访问 Admin API(管理场景) ```typescript -const client = new FlexmodelClient({ - apiKey: 'fm_ak_xxxxx', // fm_ak_ 前缀 - projectId: 'my-project', -}) +// Open API +const client = new FlexmodelClient({ apiKey: 'fm_ak_open_xxx', projectId: 'demo' }) + +// Admin API +const admin = new FlexmodelAdminClient({ apiKey: 'fm_ak_admin_xxx' }) ``` -SDK 自动将 API Key 注入为 `Authorization: Bearer fm_ak_xxxxx` 请求头。 +SDK 自动将 API Key 注入为 `Authorization: Bearer fm_ak_xxx` 请求头。跨 surface 的凭证会被后端拒绝(open key 不能访问 admin +API,反之亦然)。 + +### IdP Bearer token(Open API) + +对于配置了项目 IdP(OIDC)的场景,可直接传入 IdP 签发的 token,优先级高于 apiKey: + +```typescript +const client = new FlexmodelClient({ projectId: 'demo' }) +client.setAuthToken('idp-issued-jwt-token') + +await client.data.from('Student').findMany() +``` + +### 系统 JWT(Admin API) + +管理后台用户登录后获得的 JWT: + +```typescript +const admin = new FlexmodelAdminClient({ projectId: 'demo' }) +admin.setAuthToken('system-jwt-token') +``` + +### 匿名访问 + +当项目未配置 IdP 时,Open API 允许匿名访问(无需任何凭证): + +```typescript +const client = new FlexmodelClient({ projectId: 'demo' }) +// 直接调用,后端判定为 anonymous +``` ## 客户端初始化 +### FlexmodelClient(Open API) + ```typescript new FlexmodelClient(options?: FlexmodelClientOptions) ``` -| 参数 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `baseURL` | `string` | 否 | API 地址,浏览器默认同源(`window.location.origin`),Node/Deno 需提供 | -| `apiKey` | `string` | 否 | API Key,提供后所有请求自动注入认证头 | -| `projectId` | `string` | 否 | 数据 API 的默认项目 ID,可在 per-call 时通过 `.project()` 覆盖 | +| 参数 | 类型 | 必填 | 说明 | +|---------------|-------------------------|------|--------------------------------------------| +| `baseURL` | `string` | 否 | API 地址,浏览器默认同源,Node/Deno 需提供 | +| `apiKey` | `string` | 否 | open scope API Key | +| `authToken` | `string` | 否 | IdP Bearer token(优先级高于 apiKey) | +| `projectId` | `string` | 否 | 默认项目 ID | +| `routingMode` | `'path' \| 'subdomain'` | 否 | 路由模式,默认 `path` | + +### FlexmodelAdminClient(Admin API) ```typescript -// 最简用法(浏览器同源) -const client = new FlexmodelClient({ apiKey: 'fm_ak_xxx', projectId: 'demo' }) +new FlexmodelAdminClient(options?: AdminClientOptions) +``` + +| 参数 | 类型 | 必填 | 说明 | +|-------------|----------|------|-------------------------------| +| `baseURL` | `string` | 否 | API 地址 | +| `apiKey` | `string` | 否 | admin scope API Key | +| `authToken` | `string` | 否 | 系统 JWT(优先级高于 apiKey) | +| `projectId` | `string` | 否 | 默认项目 ID | -// 跨域场景 +## 路由模式 + +### path 模式(默认) + +多租户资源通过路径段访问,SDK 路径含 `/api/open/{projectId}` 前缀: + +``` +数据: /api/open/{projectId}/models/{model}/records +存储: /api/open/{projectId}/buckets/{bucket}/objects +GraphQL: /api/open/{projectId}/graphql +函数: /open/{projectId}/functions/{name} → Deno Runtime 直连 +``` + +### subdomain 模式 + +projectId 从 Host 提取,SDK 路径不含前缀: + +```typescript const client = new FlexmodelClient({ - baseURL: 'https://api.example.com', - apiKey: 'fm_ak_xxx', + baseURL: 'https://demo.example.com', + apiKey: 'fm_ak_open_xxx', projectId: 'demo', + routingMode: 'subdomain', }) + +// 路径为 /models/Student/records(不含 /api/open/demo) +await client.data.from('Student').findMany() + +// 函数直调路径为 /functions/myFn +await client.functions.invoke('myFn', {}) ``` ## 数据操作命名空间 -所有数据 CRUD 通过 `client.data` 命名空间访问: +两个客户端都提供 `data` 命名空间,路径前缀不同(open → `/api/open`,admin → `/api/projects`),API 完全一致。 ### `client.data.from(model)` @@ -119,34 +206,71 @@ await handle.findMany({ where: { age: { _eq: 18 } } }) Proxy 拦截属性访问,运行时等价于 `from()`: ```typescript -// 等价于 client.data.from('Student') await client.data.Student.findMany({ where: { age: { _eq: 18 } } }) ``` -V2+ 提供 schema 定义后,IDE 将自动补全模型名和字段名。 +通过 `schema()` 获得模型级类型推断后,IDE 自动补全模型名和字段名。 -### `project()` 覆盖 projectId +### CRUD 示例 ```typescript -await client.data.from('Student').project('other-project').findMany({}) -``` +// 查询 +const { list, total } = await client.data.from('Student').findMany({ + where: { classId: { _eq: 1 }, age: { _gt: 15 } }, + orderBy: 'name', + page: 1, + size: 20, +}) -## ModelHandle 便捷方法 +// 获取单条 +const student = await client.data.from('Student').findOne('001', { expand: ['classId'] }) -| 方法 | HTTP | 说明 | -|------|------|------| -| `.findMany(opts?)` | `GET` | 分页查询,返回 `PageDTO` | -| `.findOne(id, opts?)` | `GET` | 按 ID 获取单条记录 | -| `.create(data)` | `POST` | 创建单条记录 | -| `.create(data[])` | `POST` | 批量创建记录(调用 /batch 端点) | -| `.createMany(data[])` | `POST` | 批量创建记录,返回 `T[]` | -| `.update(id, { data })` | `PUT` | 全量更新 | -| `.updateMany({ data })` | `PUT` | 批量更新,每条记录必须含 id | -| `.merge(id, { data })` | `PATCH` | 部分更新 | -| `.delete(id)` | `DELETE` | 删除记录 | -| `.deleteMany({ ids })` | `DELETE` | 批量删除,返回删除数量 | -| `.count(opts?)` | `GET` | 计数,返回 `number` | -| `.query()` | — | 返回链式构建器(高级路径) | +// 创建 +const created = await client.data.from('Student').create({ name: 'Alice', age: 16, classId: 1 }) + +// 批量创建 +const batch = await client.data.from('Student').createMany([ + { name: 'Alice', age: 16 }, + { name: 'Bob', age: 17 }, +]) + +// 更新(全量替换) +await client.data.from('Student').update(1, { data: { name: 'Alicia' } }) + +// 批量更新(每条记录必须包含 id 字段) +await client.data.from('Student').updateMany({ data: [ + { id: 1, name: 'Alicia' }, + { id: 2, name: 'Bob Updated' }, +] }) + +// 更新(部分合并) +await client.data.from('Student').merge(1, { data: { name: 'Alicia' } }) + +// 删除 +await client.data.from('Student').delete(1) + +// 批量删除 +await client.data.from('Student').deleteMany({ ids: [1, 2, 3] }) + +// 计数 +const count = await client.data.from('Student').count({ where: { age: { _gt: 18 } } }) +``` + +### ModelHandle 便捷方法 + +| 方法 | HTTP | 说明 | +|-------------------------|----------|-----------------------------| +| `.findMany(opts?)` | `GET` | 分页查询,返回 `PageDTO` | +| `.findOne(id, opts?)` | `GET` | 按 ID 获取单条记录 | +| `.create(data)` | `POST` | 创建单条记录 | +| `.createMany(data[])` | `POST` | 批量创建记录,返回 `T[]` | +| `.update(id, { data })` | `PUT` | 全量更新 | +| `.updateMany({ data })` | `PUT` | 批量更新,每条记录必须含 id | +| `.merge(id, { data })` | `PATCH` | 部分更新 | +| `.delete(id)` | `DELETE` | 删除记录 | +| `.deleteMany({ ids })` | `DELETE` | 批量删除 | +| `.count(opts?)` | `GET` | 计数,返回 `number` | +| `.query()` | — | 返回链式构建器(高级路径) | ### findMany 选项 @@ -173,113 +297,122 @@ await client.data.from('Student').findMany({ }) ``` -**关联加载简写**:`'class,teacher'` 或 `['class', 'teacher']`。 +## 边缘函数(Functions) -### findOne 选项 +Open client 的 `functions` 命名空间直连 Deno Runtime 调用边缘函数,不经过 Java 代理。凭证由 Deno 通过 Java +`/api/edge/validate` 校验。 ```typescript -await client.data.from('Student').findOne('001', { expand: ['classId'] }) +// 直调 Deno Runtime +const result = await client.functions.invoke('myFn', { key: 'value' }) ``` -### 创建 +| 路由模式 | 调用路径 | +|-----------|-----------------------------------------------| +| path | `{baseURL}/open/{projectId}/functions/{name}` | +| subdomain | `{baseURL}/functions/{name}` | -```typescript -// 单条 -const created = await client.data.from('Student').create({ name: 'Alice', age: 16 }) +> Admin client 的 `functions` 命名空间是管理操作(deploy/delete/list/get),不含 invoke。 -// 批量(传入数组自动调用 /batch 端点) -const batch = await client.data.from('Student').create([ - { name: 'Alice', age: 16 }, - { name: 'Bob', age: 17 }, -]) +## 对象存储(Storage) -// 显式批量创建 -const batch2 = await client.data.from('Student').createMany([ - { name: 'Alice', age: 16 }, - { name: 'Bob', age: 17 }, -]) -``` +Open client 的 `storage` 命名空间提供对象读写: -### 批量更新 +```typescript +// 列出对象 +const files = await client.storage.list('my-bucket', 'photos/') -每条记录必须包含 `id` 字段: +// 上传 +await client.storage.upload('my-bucket', 'photo.jpg', fileBlob) -```typescript -const updated = await client.data.from('Student').updateMany({ - data: [ - { id: 1, name: 'Alicia' }, - { id: 2, name: 'Bob Updated' }, - ], -}) +// 下载 +const blob = await client.storage.download('my-bucket', 'photo.jpg') + +// 获取元数据 +const meta = await client.storage.head('my-bucket', 'photo.jpg') + +// 删除 +await client.storage.delete('my-bucket', 'photo.jpg') ``` -### 批量删除 +## GraphQL + +Open client 的 `graphql` 命名空间执行 GraphQL 查询: ```typescript -const deletedCount = await client.data.from('Student').deleteMany({ ids: [1, 2, 3] }) +const result = await client.graphql(`{ Student { id name age } }`) + +// 带变量 +const result = await client.graphql( + `query GetStudent($id: ID!) { Student(id: $id) { name } }`, + { id: '001' }, +) ``` -> 批量操作上限为 **200 条**记录,超出将返回 HTTP 400 错误。 +## 管理命名空间(Admin API) + +`FlexmodelAdminClient` 提供以下管理命名空间: -### 计数 +### `admin.projects` ```typescript -const total = await client.data.from('Student').count({ where: { age: { _gt: 18 } } }) +const projects = await admin.projects.list() +const project = await admin.projects.get('demo') +await admin.projects.create({ name: 'New Project' }) +await admin.projects.update('demo', { name: 'Updated' }) +await admin.projects.delete('demo') ``` -## 过滤器 DSL - -`where` 选项使用 JSON 过滤器 DSL,直接对应后端 `ConditionOperator`: +### `admin.users` -### 字段操作符 +```typescript +const users = await admin.users.list() +await admin.users.create({ username: 'alice' }) +await admin.users.delete('user-001') +``` -| 操作符 | 后端 operator | 示例 | -|--------|-------------|------| -| `_eq` | `EQ` | `{ age: { _eq: 18 } }` | -| `_ne` | `NE` | `{ status: { _ne: 'disabled' } }` | -| `_gt` | `GT` | `{ age: { _gt: 15 } }` | -| `_gte` | `GTE` | `{ score: { _gte: 60 } }` | -| `_lt` | `LT` | `{ age: { _lt: 18 } }` | -| `_lte` | `LTE` | `{ price: { _lte: 100 } }` | -| `_in` | `IN` | `{ role: { _in: ['admin', 'user'] } }` | -| `_nin` | `NIN` | `{ status: { _nin: ['deleted'] } }` | -| `_between` | `BETWEEN` | `{ age: { _between: [10, 20] } }` | -| `_contains` | `CONTAINS` | `{ name: { _contains: 'li' } }` | -| `_not_contains` | `NOT_CONTAINS` | `{ bio: { _not_contains: 'spam' } }` | -| `_starts_with` | `STARTS_WITH` | `{ email: { _starts_with: 'a@' } }` | -| `_ends_with` | `ENDS_WITH` | `{ email: { _ends_with: '.com' } }` | +### `admin.apiKeys` -### 逻辑组合 +```typescript +const apiKeys = await admin.apiKeys.list() +const key = await admin.apiKeys.create({ name: 'my-key', scope: 'open', readOnly: false }) +await admin.apiKeys.regenerate('key-id') +await admin.apiKeys.delete('key-id') +``` -多字段自动 **AND**(并列字段): +### `admin.functions`(管理操作) ```typescript -{ classId: { _eq: 1 }, age: { _gt: 15 } } -// → 两个条件同时满足 +const fns = await admin.functions.list('demo') +const fn = await admin.functions.get('demo', 'myFn') +await admin.functions.deploy('demo', 'myFn', { + sourceFiles: { 'index.ts': 'export default async (req) => req.json()' }, +}) +await admin.functions.delete('demo', 'myFn') ``` -显式逻辑操作符: +## 过滤器 + +### 操作符 + +| 类别 | 操作符 | +|--------|------------------------------------------------------| +| 比较 | `_eq` `_ne` `_gt` `_gte` `_lt` `_lte` | +| 集合 | `_in` `_nin` `_between` `_notBetween` | +| 字符串 | `_contains` `_notContains` `_startsWith` `_endsWith` | +| 空值 | `_null` `_notNull` | +| 逻辑 | `_and` `_or` | ```typescript -// OR -{ _or: [{ classId: { _eq: 1 } }, { age: { _gt: 15 } }] } - -// AND -{ _and: [{ classId: { _eq: 1 } }, { age: { _gt: 15 } }] } - -// 嵌套组合 -{ - _or: [ - { _and: [{ classId: { _eq: 1 } }, { age: { _gt: 15 } }] }, - { _and: [{ classId: { _eq: 2 } }, { age: { _lt: 12 } }] }, - ] -} +await client.data.from('Student').findMany({ + where: { + _and: [{ classId: { _eq: 1 } }, { age: { _gt: 15 } }], + }, +}) ``` ### 便捷函数式构造 -SDK 提供 `filter-builder` 中的独立函数,用于在代码中动态构建过滤条件: - ```typescript import { filterEq, filterGt, filterOr, filterAnd } from '@flexmodel/sdk' @@ -326,17 +459,32 @@ const result = await client.data.from('Student').query() | **终端方法** | `.execute()` | 执行,返回取决于操作类型 | | | `.single()` | 获取第一条,无匹配返回 null | -### where() 回调 +## 单例与便捷导出 -链式构建器中的 `where()` 接收 `FilterFn` 对象,提供与 `filter-builder` 相同的函数: +SDK 预初始化了两个单例,适合在浏览器/边缘函数中直接使用: ```typescript -client.data.from('Student').query().where((f) => - f.or( - f.and(f.eq('classId', 1), f.gt('age', 15)), - f.and(f.eq('classId', 2), f.lt('age', 12)), - ) -).execute() +import { flexmodelClient, adminClient, configure, configureAdmin, data } from '@flexmodel/sdk' + +// 配置 Open API 单例 +configure({ + baseURL: 'https://api.example.com', + apiKey: 'fm_ak_open_xxx', + projectId: 'demo', +}) + +// 使用 data 便捷导出(等价于 flexmodelClient.data) +const students = await data.Student.findMany() + +// 调用函数 +const result = await flexmodelClient.functions.invoke('myFn', {}) + +// 配置 Admin API 单例 +configureAdmin({ + baseURL: 'https://api.example.com', + apiKey: 'fm_ak_admin_xxx', +}) +const projects = await adminClient.projects.list() ``` ## 类型安全 @@ -360,15 +508,6 @@ const db = client.schema() // db.data.Student 有类型推断 db.data.Student.findMany({ where: { age: { _eq: 18 } } }) db.data.Student.findOne('001') - -// db.data.from('Student') 同样有类型推断 -db.data.from('Student').findMany({ where: { age: { _eq: 18 } } }) -``` - -不使用 schema 也可用(字段名为 `string`,模型为 `Record`): - -```typescript -client.data.from('Student').findMany({ where: { age: { _eq: 18 } } }) ``` ## 错误处理 @@ -380,11 +519,10 @@ try { await client.data.from('Student').findOne(999) } catch (err) { if (err instanceof FlexmodelApiError) { - // { status: 404, code: -1, message: 'Record not found', details: ... } console.log(err.status, err.code, err.message) } if (err instanceof FlexmodelAuthError) { - // API Key 无效或无权限访问该项目 + // API Key 无效或无权限 console.log(err.message) } } @@ -400,23 +538,27 @@ try { SDK 零外部依赖,仅使用 `fetch` 等标准 API: - **浏览器** — 直接可用 - **Node.js** — Node 18+ 已内置 fetch -- **Deno** — 直接可用 +- **Deno** — 直接可用(边缘函数 Worker 中使用) ## 模块结构 ``` -flexmodel-sdks/ +flexmodel-sdks/typescript/ ├── src/ │ ├── index.ts # 主入口,导出所有公共 API -│ ├── client.ts # FlexmodelClient — 泛型客户端 +│ ├── client.ts # FlexmodelClient + FlexmodelAdminClient + 单例 │ ├── data-namespace.ts # DataNamespace — Proxy 命名空间 │ ├── model-handle.ts # ModelHandle — 便捷 CRUD 方法 -│ ├── query-builder.ts # FluentQueryBuilder — 链式构建器(高级路径) +│ ├── query-builder.ts # FluentQueryBuilder — 链式构建器 │ ├── filter-builder.ts # 过滤器构造函数 + FilterFn │ ├── filter-serializer.ts # 过滤器/排序序列化 +│ ├── functions-namespace.ts # 边缘函数直调(Open API) +│ ├── storage-namespace.ts # 对象存储(Open API) +│ ├── graphql-namespace.ts # GraphQL 查询(Open API) +│ ├── admin-namespaces.ts # 管理命名空间:projects/users/apiKeys/functions +│ ├── http.ts # HTTP 传输层(fetch wrapper) │ ├── types.ts # 公共类型定义 │ ├── errors.ts # 错误类 -│ ├── http.ts # HTTP 传输层(fetch wrapper) │ └── type-helpers.ts # Schema, RelationToOne, RelationToMany ├── tests/ ├── package.json diff --git a/typescript/package.json b/typescript/package.json index 3bcf418..00ad472 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@flexmodel/sdk", - "version": "0.0.6", + "version": "0.0.7", "description": "Flexmodel TypeScript SDK — Fluent query builder for data CRUD with API Key auth", "type": "module", "main": "./dist/index.cjs", diff --git a/typescript/src/admin-namespaces.ts b/typescript/src/admin-namespaces.ts new file mode 100644 index 0000000..5c29227 --- /dev/null +++ b/typescript/src/admin-namespaces.ts @@ -0,0 +1,170 @@ +// ============================================================ +// Flexmodel SDK — Admin Namespaces +// +// Thin namespaces for admin API operations (project/user/apikey management). +// Only available on FlexmodelAdminClient. +// ============================================================ + +import type {HttpTransport} from './http.js' + +// ---- Types ---- + +export interface Project { + id: string + name: string + databaseName?: string + + [key: string]: unknown +} + +export interface CreateProjectRequest { + name: string + + [key: string]: unknown +} + +export interface User { + id: string + username?: string + + [key: string]: unknown +} + +export interface ApiKey { + id: string + name: string + keyPrefix: string + scope: 'admin' | 'open' + projectIds?: string + readOnly: boolean + expiresAt?: string | null + lastUsedAt?: string | null + createdAt?: string + key?: string | null +} + +export interface CreateApiKeyRequest { + name: string + scope?: string + projectIds?: string + readOnly?: boolean +} + +export interface AdminFunction { + id: string + name: string + + [key: string]: unknown +} + +export interface DeployFunctionRequest { + sourceFiles: Record + + [key: string]: unknown +} + +// ---- ProjectsNamespace ---- + +export class ProjectsNamespace { + private readonly http: HttpTransport + + constructor(http: HttpTransport) { + this.http = http + } + + async list(): Promise { + return this.http.request('GET', '/api/projects') + } + + async create(data: CreateProjectRequest): Promise { + return this.http.request('POST', '/api/projects', {body: data}) + } + + async get(id: string): Promise { + return this.http.request('GET', `/api/projects/${id}`) + } + + async update(id: string, data: Partial): Promise { + return this.http.request('PUT', `/api/projects/${id}`, {body: data}) + } + + async delete(id: string): Promise { + await this.http.request('DELETE', `/api/projects/${id}`) + } +} + +// ---- UsersNamespace ---- + +export class UsersNamespace { + private readonly http: HttpTransport + + constructor(http: HttpTransport) { + this.http = http + } + + async list(): Promise { + return this.http.request('GET', '/api/users') + } + + async create(data: Record): Promise { + return this.http.request('POST', '/api/users', {body: data}) + } + + async delete(id: string): Promise { + await this.http.request('DELETE', `/api/users/${id}`) + } +} + +// ---- ApiKeysNamespace ---- + +export class ApiKeysNamespace { + private readonly http: HttpTransport + + constructor(http: HttpTransport) { + this.http = http + } + + async list(): Promise { + return this.http.request('GET', '/api/api-keys') + } + + async create(data: CreateApiKeyRequest): Promise { + return this.http.request('POST', '/api/api-keys', {body: data}) + } + + async regenerate(id: string): Promise { + return this.http.request('POST', `/api/api-keys/${id}/regenerate`) + } + + async delete(id: string): Promise { + await this.http.request('DELETE', `/api/api-keys/${id}`) + } +} + +// ---- AdminFunctionsNamespace ---- + +export class AdminFunctionsNamespace { + private readonly http: HttpTransport + + constructor(http: HttpTransport) { + this.http = http + } + + async list(projectId: string): Promise { + return this.http.request('GET', `/api/projects/${projectId}/functions`) + } + + async get(projectId: string, name: string): Promise { + return this.http.request('GET', `/api/projects/${projectId}/functions/${name}`) + } + + async deploy(projectId: string, name: string, sourceFiles: Record): Promise { + return this.http.request('POST', `/api/projects/${projectId}/functions/${name}/deploy`, { + body: {sourceFiles}, + }) + } + + async delete(projectId: string, name: string): Promise { + await this.http.request('DELETE', `/api/projects/${projectId}/functions/${name}`) + } +} diff --git a/typescript/src/client.ts b/typescript/src/client.ts index 41e03a2..ff9b57d 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -1,14 +1,21 @@ // ============================================================ -// Flexmodel SDK — FlexmodelClient +// Flexmodel SDK — FlexmodelClient (Open) + FlexmodelAdminClient (Admin) // -// Main entry point. Creates DataNamespace for data CRUD. -// Future namespaces (auth, schema, storage, functions) will -// be added as sibling properties. +// Two client classes sharing HttpTransport + DataNamespace internals: +// - FlexmodelClient: open API (terminal users, IdP/open-scope key) +// - FlexmodelAdminClient: admin API (management tools, JWT/admin-scope key) // ============================================================ import { HttpTransport } from './http.js' import { DataNamespace } from './data-namespace.js' -import { ModelHandle } from './model-handle.js' +import {ModelHandle, type ProjectBasePathBuilder} from './model-handle.js' +import {FunctionsNamespace} from './functions-namespace.js' +import {StorageNamespace, type FileItem} from './storage-namespace.js' +import {GraphQLNamespace, type GraphQLResult} from './graphql-namespace.js' +import { + ProjectsNamespace, UsersNamespace, ApiKeysNamespace, AdminFunctionsNamespace, + type Project, type User, type ApiKey, type CreateApiKeyRequest, type CreateProjectRequest, +} from './admin-namespaces.js' import type { FlexmodelClientOptions } from './types.js' type SchemaMap = Record> @@ -19,34 +26,56 @@ export interface ConfigureOptions extends FlexmodelClientOptions { authToken?: string } +/** Admin client 配置选项 */ +export interface AdminClientOptions { + baseURL?: string + apiKey?: string + authToken?: string + projectId?: string +} + +// ---- Path builders ---- + +/** Open client: /api/open/{projectId}(path 和 subdomain 模式统一) */ +const openPathBuilder: ProjectBasePathBuilder = (pid) => `/api/open/${pid}` +/** Admin client: /api/projects/{projectId} */ +const adminPathBuilder: ProjectBasePathBuilder = (pid) => `/api/projects/${pid}` + +// ============================================================ +// FlexmodelClient — Open API (terminal users) +// ============================================================ + /** - * Flexmodel SDK 客户端。 + * Flexmodel SDK 客户端(Open API)。 + * + * 面向终端用户:前端应用、边缘函数。 + * 认证方式:open scope API Key 或 IdP Bearer token。 + * 路径前缀:/api/open/{projectId}/... * * @example * const client = new FlexmodelClient({ - * apiKey: 'fm_ak_xxxxx', + * apiKey: 'fm_ak_open_xxxxx', * projectId: 'demo', * }) * - * // 便捷方法 * const { list, total } = await client.data.from('Student').findMany({ * where: { age: { _eq: 18 } }, - * orderBy: 'name', * page: 1, * size: 20, * }) * - * // Proxy 访问(等价于 from()) - * const { list, total } = await client.data.Student.findMany({ - * where: { age: { _eq: 18 } }, - * }) + * const result = await client.functions.invoke('myFn', { key: 'value' }) */ export class FlexmodelClient< TSchema extends SchemaMap = SchemaMap, > { private readonly http: HttpTransport private defaultProjectId?: string + private readonly projectBasePath: ProjectBasePathBuilder private readonly namespace: DataNamespace + private readonly functionsNs: FunctionsNamespace + private readonly storageNs: StorageNamespace + private readonly graphqlNs: GraphQLNamespace /** * 数据操作命名空间。 @@ -54,70 +83,156 @@ export class FlexmodelClient< */ readonly data: DataNamespace & { [K in keyof TSchema]: ModelHandle } + /** 边缘函数命名空间 */ + readonly functions: FunctionsNamespace + + /** 对象存储命名空间 */ + readonly storage: StorageNamespace + + /** GraphQL 命名空间 */ + readonly graphql: GraphQLNamespace + constructor(options: FlexmodelClientOptions = {}) { const baseURL = options.baseURL ?? this.getDefaultBaseURL() this.http = new HttpTransport(baseURL, options.apiKey) this.defaultProjectId = options.projectId + this.projectBasePath = openPathBuilder - this.namespace = new DataNamespace(this.http, this.defaultProjectId) + this.namespace = new DataNamespace(this.http, this.projectBasePath, this.defaultProjectId) this.data = this.namespace.asProxy() + this.functionsNs = new FunctionsNamespace(this.http, this.projectBasePath, this.defaultProjectId) + this.storageNs = new StorageNamespace(this.http, this.projectBasePath, this.defaultProjectId) + this.graphqlNs = new GraphQLNamespace(this.http, this.projectBasePath, this.defaultProjectId) + this.functions = this.functionsNs + this.storage = this.storageNs + this.graphql = this.graphqlNs } /** * 创建带类型约束的客户端实例。 - * 传入 Schema interface 后,data.Student 等属性获得类型推断。 - * - * @example - * interface MySchema { - * Student: { id: number; name: string; age: number } - * } - * const db = client.schema() - * db.data.Student.findMany({ where: { age: { _eq: 18 } } }) // Student 有类型提示 */ schema(): FlexmodelClient { - // schema() 是纯类型级操作,运行时行为不变 - // cast 是安全的:DataNamespace 的 Proxy 已经能拦截任意属性 return this as unknown as FlexmodelClient } - /** - * 设置当前请求的认证令牌(优先级高于构造函数中的 apiKey)。 - * 传入 undefined 清除,恢复使用默认 apiKey。 - */ + /** 设置当前请求的认证令牌(优先级高于构造函数中的 apiKey)。 */ setAuthToken(token?: string): void { this.http.setAuthToken(token) } - /** - * 更新 API 基础地址(运行时可变)。 - * 自动去除尾部斜杠。 - */ + /** 更新 API 基础地址(运行时可变)。 */ setBaseURL(baseURL: string): void { this.http.setBaseURL(baseURL) } - /** - * 更新 API Key(运行时可变)。 - * 传入 undefined 清除,后续请求不再注入 Authorization 头(除非设置了 authToken)。 - */ + /** 更新 API Key(运行时可变)。 */ setApiKey(apiKey?: string): void { this.http.setApiKey(apiKey) } /** * 设置默认 projectId(运行时可变)。 - * 更新客户端及 DataNamespace 的默认 projectId,并清空已缓存的 ModelHandle, - * 使后续 from() 调用以新 projectId 重建句柄。 - * - * @example - * flexmodelClient.setProjectId('my-project') + * 更新所有命名空间的默认 projectId。 */ + setProjectId(projectId?: string): void { + this.defaultProjectId = projectId + this.namespace.updateDefaultProjectId(projectId) + this.functionsNs.updateDefaultProjectId(projectId) + this.storageNs.updateDefaultProjectId(projectId) + this.graphqlNs.updateDefaultProjectId(projectId) + } + + /** 浏览器环境下默认同源,Node/Deno 需显式提供 baseURL */ + private getDefaultBaseURL(): string { + if (typeof globalThis !== 'undefined' && 'location' in globalThis) { + return (globalThis as { location: { origin: string } }).location.origin + } + return '' + } +} + +// ============================================================ +// FlexmodelAdminClient — Admin API (management tools) +// ============================================================ + +/** + * Flexmodel SDK 管理客户端(Admin API)。 + * + * 面向管理工具:后端脚本、CI/CD、管理后台。 + * 认证方式:系统 JWT 或 admin scope API Key。 + * 路径前缀:/api/projects/{projectId}/...、/api/users、/api/api-keys + * + * @example + * const admin = new FlexmodelAdminClient({ + * apiKey: 'fm_ak_admin_xxxxx', + * }) + * + * const projects = await admin.projects.list() + * await admin.functions.deploy('demo', 'myFn', { 'index.ts': '...' }) + * const students = await admin.data.from('Student').findMany() + */ +export class FlexmodelAdminClient< + TSchema extends SchemaMap = SchemaMap, +> { + private readonly http: HttpTransport + private defaultProjectId?: string + private readonly namespace: DataNamespace + + /** 数据操作命名空间(admin 路径) */ + readonly data: DataNamespace & { [K in keyof TSchema]: ModelHandle } + + /** 项目管理命名空间 */ + readonly projects: ProjectsNamespace + + /** 用户管理命名空间 */ + readonly users: UsersNamespace + + /** API Key 管理命名空间 */ + readonly apiKeys: ApiKeysNamespace + + /** 函数管理命名空间(deploy/delete/list) */ + readonly functions: AdminFunctionsNamespace + + constructor(options: AdminClientOptions = {}) { + const baseURL = options.baseURL ?? this.getDefaultBaseURL() + this.http = new HttpTransport(baseURL, options.apiKey) + if (options.authToken) this.http.setAuthToken(options.authToken) + this.defaultProjectId = options.projectId + + this.namespace = new DataNamespace(this.http, adminPathBuilder, this.defaultProjectId) + this.data = this.namespace.asProxy() + this.projects = new ProjectsNamespace(this.http) + this.users = new UsersNamespace(this.http) + this.apiKeys = new ApiKeysNamespace(this.http) + this.functions = new AdminFunctionsNamespace(this.http) + } + + /** 创建带类型约束的客户端实例。 */ + schema(): FlexmodelAdminClient { + return this as unknown as FlexmodelAdminClient + } + + /** 设置认证令牌。 */ + setAuthToken(token?: string): void { + this.http.setAuthToken(token) + } + + /** 更新 API 基础地址。 */ + setBaseURL(baseURL: string): void { + this.http.setBaseURL(baseURL) + } + + /** 更新 API Key。 */ + setApiKey(apiKey?: string): void { + this.http.setApiKey(apiKey) + } + + /** 设置默认 projectId。 */ setProjectId(projectId?: string): void { this.defaultProjectId = projectId this.namespace.updateDefaultProjectId(projectId) } - /** 浏览器环境下默认同源,Node/Deno 需显式提供 baseURL */ private getDefaultBaseURL(): string { if (typeof globalThis !== 'undefined' && 'location' in globalThis) { return (globalThis as { location: { origin: string } }).location.origin @@ -127,7 +242,7 @@ export class FlexmodelClient< } // ============================================================ -// 预初始化单例 — 从环境变量读取 baseURL +// 预初始化单例 // ============================================================ function getEnvBaseURL(): string { @@ -148,18 +263,22 @@ function getEnvBaseURL(): string { return '' } +/** Open API 单例(终端用户主场景) */ export const flexmodelClient = new FlexmodelClient({ baseURL: getEnvBaseURL(), }) +/** Admin API 单例 */ +export const adminClient = new FlexmodelAdminClient({ + baseURL: getEnvBaseURL(), +}) + /** - * 配置全局单例的便捷函数。 - * 修改 flexmodelClient 的 baseURL、apiKey、authToken、projectId, - * 后续通过 `data` 导出的操作将使用新配置。 + * 配置 open API 单例的便捷函数。 * * @example * import { data, configure } from '@flexmodel/sdk' - * configure({ baseURL: 'https://api.example.com', apiKey: 'fm_ak_xxx', projectId: 'demo' }) + * configure({ baseURL: 'https://api.example.com', apiKey: 'fm_ak_open_xxx', projectId: 'demo' }) * const students = await data.Student.findMany() */ export function configure(options: ConfigureOptions = {}): void { @@ -178,9 +297,30 @@ export function configure(options: ConfigureOptions = {}): void { } /** - * 数据操作命名空间的便捷导出。 - * 直接引用 flexmodelClient.data 的 Proxy 实例, - * 支持 data.Student.findMany() 等简写。 + * 配置 admin API 单例的便捷函数。 + * + * @example + * import { adminClient, configureAdmin } from '@flexmodel/sdk' + * configureAdmin({ baseURL: 'https://api.example.com', apiKey: 'fm_ak_admin_xxx' }) + * const projects = await adminClient.projects.list() + */ +export function configureAdmin(options: AdminClientOptions = {}): void { + if (options.baseURL !== undefined) { + adminClient.setBaseURL(options.baseURL) + } + if (options.apiKey !== undefined) { + adminClient.setApiKey(options.apiKey) + } + if (options.authToken !== undefined) { + adminClient.setAuthToken(options.authToken) + } + if (options.projectId !== undefined) { + adminClient.setProjectId(options.projectId) + } +} + +/** + * 数据操作命名空间的便捷导出(open API 单例)。 * * @example * import { data } from '@flexmodel/sdk' diff --git a/typescript/src/data-namespace.ts b/typescript/src/data-namespace.ts index eeb9347..ff1ae8d 100644 --- a/typescript/src/data-namespace.ts +++ b/typescript/src/data-namespace.ts @@ -8,6 +8,7 @@ // ============================================================ import type { HttpTransport } from './http.js' +import type {ProjectBasePathBuilder} from './model-handle.js' import { ModelHandle } from './model-handle.js' type SchemaMap = Record> @@ -19,17 +20,19 @@ type SchemaMap = Record> * // V1: 显式选模型 * const students = await client.data.from('Student').findMany({ where: { age: { _eq: 18 } } }) * - * // Proxy 访问(运行时等价于 from()) + * // V1: Proxy 简写 * const students = await client.data.Student.findMany({ where: { age: { _eq: 18 } } }) */ export class DataNamespace { private readonly http: HttpTransport + private readonly projectBasePath: ProjectBasePathBuilder private defaultProjectId?: string private readonly models = new Map>>() private readonly proxy: DataNamespace & { [K in keyof TSchema]: ModelHandle } - constructor(http: HttpTransport, defaultProjectId?: string) { + constructor(http: HttpTransport, projectBasePath: ProjectBasePathBuilder, defaultProjectId?: string) { this.http = http + this.projectBasePath = projectBasePath this.defaultProjectId = defaultProjectId this.proxy = this.createProxy() } @@ -55,7 +58,7 @@ export class DataNamespace { const cached = this.models.get(modelName) if (cached) return cached as ModelHandle - const handle = new ModelHandle(this.http, modelName, this.defaultProjectId) + const handle = new ModelHandle(this.http, modelName, this.projectBasePath, this.defaultProjectId) this.models.set(modelName, handle as ModelHandle>) return handle } diff --git a/typescript/src/functions-namespace.ts b/typescript/src/functions-namespace.ts new file mode 100644 index 0000000..a0519ac --- /dev/null +++ b/typescript/src/functions-namespace.ts @@ -0,0 +1,49 @@ +// ============================================================ +// Flexmodel SDK — FunctionsNamespace (Open API) +// +// Edge function invoke — calls the Java server (open API proxy), +// which forwards to the Deno Runtime internally. +// +// Maps to backend OpenFunctionResource: +// POST /api/open/{projectId}/functions/{name}/invoke +// ============================================================ + +import type {HttpTransport} from './http.js' +import type {ProjectBasePathBuilder} from './model-handle.js' + +export class FunctionsNamespace { + private readonly http: HttpTransport + private readonly projectBasePath: ProjectBasePathBuilder + private defaultProjectId?: string + + constructor(http: HttpTransport, projectBasePath: ProjectBasePathBuilder, defaultProjectId?: string) { + this.http = http + this.projectBasePath = projectBasePath + this.defaultProjectId = defaultProjectId + } + + /** 更新默认 projectId */ + updateDefaultProjectId(projectId?: string): void { + this.defaultProjectId = projectId + } + + private resolveProjectId(projectId?: string): string { + const pid = projectId ?? this.defaultProjectId + if (!pid) { + throw new Error('projectId is required for function operations. Set it via FlexmodelClient({ projectId }).') + } + return pid + } + + /** + * 调用边缘函数(通过 Java 服务端代理转发到 Deno runtime)。 + * + * @example + * const result = await client.functions.invoke('myFn', { key: 'value' }) + */ + async invoke(name: string, input?: unknown, projectId?: string): Promise { + const pid = this.resolveProjectId(projectId) + const path = `${this.projectBasePath(pid)}/functions/${name}/invoke` + return this.http.request('POST', path, {body: input}) + } +} diff --git a/typescript/src/graphql-namespace.ts b/typescript/src/graphql-namespace.ts new file mode 100644 index 0000000..b601cc6 --- /dev/null +++ b/typescript/src/graphql-namespace.ts @@ -0,0 +1,64 @@ +// ============================================================ +// Flexmodel SDK — GraphQLNamespace +// +// GraphQL query execution. +// Maps to backend OpenGraphQLResource / GraphQLResource: +// POST /open/{projectId}/graphql (open client) +// POST /api/projects/{projectId}/graphql (admin client) +// ============================================================ + +import type {HttpTransport} from './http.js' +import type {ProjectBasePathBuilder} from './model-handle.js' + +/** GraphQL 请求体 */ +export interface GraphQLRequest { + operationName?: string + query: string + variables?: Record +} + +/** GraphQL 执行结果 */ +export interface GraphQLResult { + data?: T + errors?: Array<{ message: string; [key: string]: unknown }> + + [key: string]: unknown +} + +export class GraphQLNamespace { + private readonly http: HttpTransport + private readonly projectBasePath: ProjectBasePathBuilder + private defaultProjectId?: string + + constructor(http: HttpTransport, projectBasePath: ProjectBasePathBuilder, defaultProjectId?: string) { + this.http = http + this.projectBasePath = projectBasePath + this.defaultProjectId = defaultProjectId + } + + /** 更新默认 projectId */ + updateDefaultProjectId(projectId?: string): void { + this.defaultProjectId = projectId + } + + private resolveProjectId(projectId?: string): string { + const pid = projectId ?? this.defaultProjectId + if (!pid) { + throw new Error('projectId is required for GraphQL operations. Set it via FlexmodelClient({ projectId }).') + } + return pid + } + + /** + * 执行 GraphQL 查询。 + * + * @example + * const result = await client.graphql(`{ Student { id name } }`) + */ + async execute(query: string, variables?: Record, operationName?: string, projectId?: string): Promise> { + const pid = this.resolveProjectId(projectId) + const path = `${this.projectBasePath(pid)}/graphql` + const body: GraphQLRequest = {query, variables, operationName} + return this.http.request>('POST', path, {body}) + } +} diff --git a/typescript/src/http.ts b/typescript/src/http.ts index 17c2111..67862c5 100644 --- a/typescript/src/http.ts +++ b/typescript/src/http.ts @@ -45,11 +45,40 @@ export class HttpTransport { this.apiKey = apiKey } + /** + * 构建完整 URL(公开方法,供 StorageNamespace 等直接调用 fetch 时使用)。 + */ + buildUrl(path: string, params?: Record): string { + const url = `${this.baseURL}${path}` + if (!params) return url + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + searchParams.set(key, value) + } + } + const qs = searchParams.toString() + return qs ? `${url}?${qs}` : url + } + + /** + * 返回当前认证 headers(公开方法,供 StorageNamespace 等直接调用 fetch 时使用)。 + */ + authHeaders(): Record { + const headers: Record = {} + if (this.activeToken) { + headers['Authorization'] = `Bearer ${this.activeToken}` + } else if (this.apiKey) { + headers['Authorization'] = `Bearer ${this.apiKey}` + } + return headers + } + /** * 发送 HTTP 请求。 * * @param method HTTP 方法(GET/POST/PUT/PATCH/DELETE) - * @param path 相对路径,如 /projects/demo/models/Student/records + * @param path 相对路径,如 /api/open/demo/models/Student/records * @param options 可选的 params / body / headers */ async request(method: string, path: string, options?: RequestOptions): Promise { @@ -58,19 +87,14 @@ export class HttpTransport { const headers: Record = { 'content-type': 'application/json', accept: 'application/json', + ...this.authHeaders(), ...options?.headers, } - if (this.activeToken) { - headers['Authorization'] = `Bearer ${this.activeToken}` - } else if (this.apiKey) { - headers['Authorization'] = `Bearer ${this.apiKey}` - } - const init: RequestInit = { method, headers } if (options?.body !== undefined) { - init.body = JSON.stringify(options.body) + init.body = typeof options.body === 'string' ? options.body : JSON.stringify(options.body) } const response = await fetch(url, init) @@ -99,19 +123,6 @@ export class HttpTransport { return response.json() as Promise } - - private buildUrl(path: string, params?: Record): string { - const url = `${this.baseURL}${path}` - if (!params) return url - const searchParams = new URLSearchParams() - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) { - searchParams.set(key, value) - } - } - const qs = searchParams.toString() - return qs ? `${url}?${qs}` : url - } } /** 安全解析 JSON,失败返回 null */ diff --git a/typescript/src/index.ts b/typescript/src/index.ts index faedf2d..8a28fd1 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -2,14 +2,36 @@ // Flexmodel SDK — Public API Entry Point // ============================================================ -export { FlexmodelClient, flexmodelClient, configure, data } from './client.js' -export type { ConfigureOptions } from './client.js' +// Clients +export { + FlexmodelClient, FlexmodelAdminClient, flexmodelClient, adminClient, configure, configureAdmin, data +} from './client.js' +export type {ConfigureOptions, AdminClientOptions} from './client.js' + +// Data layer export { DataNamespace } from './data-namespace.js' -export { ModelHandle } from './model-handle.js' +export {ModelHandle, type ProjectBasePathBuilder} from './model-handle.js' export { FluentQueryBuilder } from './query-builder.js' +export {normalizeSorts, normalizeFields} from './model-handle.js' + +// Open API namespaces +export {FunctionsNamespace} from './functions-namespace.js' +export {StorageNamespace, type FileItem} from './storage-namespace.js' +export {GraphQLNamespace, type GraphQLResult, type GraphQLRequest} from './graphql-namespace.js' + +// Admin API namespaces +export { + ProjectsNamespace, UsersNamespace, ApiKeysNamespace, AdminFunctionsNamespace, + type Project, type User, type ApiKey, type CreateApiKeyRequest, type CreateProjectRequest, +} from './admin-namespaces.js' + +// HTTP export { HttpTransport } from './http.js' + +// Errors export { FlexmodelError, FlexmodelApiError, FlexmodelAuthError } from './errors.js' -export { normalizeSorts, normalizeFields } from './model-handle.js' + +// Types export type { FlexmodelClientOptions, PageDTO, diff --git a/typescript/src/model-handle.ts b/typescript/src/model-handle.ts index 44ade80..d67a545 100644 --- a/typescript/src/model-handle.ts +++ b/typescript/src/model-handle.ts @@ -4,6 +4,10 @@ // Per-model convenience methods for data CRUD. // Each ModelHandle holds model name + projectId context and // delegates directly to HttpTransport — no chain builder needed. +// +// The projectBasePath builder determines the URL prefix: +// - open client: (pid) => `/api/open/${pid}` +// - admin client: (pid) => `/api/projects/${pid}` // ============================================================ import type { HttpTransport } from './http.js' @@ -16,6 +20,9 @@ import type { import { serializeFilters, serializeSorts } from './filter-serializer.js' import { FluentQueryBuilder } from './query-builder.js' +/** Builds the project-scoped base path (e.g. `/api/open/demo` or `/api/projects/demo`) */ +export type ProjectBasePathBuilder = (projectId: string) => string + // ---- Normalization helpers ---- /** 将 SortInput 归一化为 SortItem[] */ @@ -64,24 +71,27 @@ export function normalizeFields(input?: FieldSelection): string[] { export class ModelHandle> { private readonly http: HttpTransport private readonly modelName: string + private readonly projectBasePath: ProjectBasePathBuilder private readonly defaultProjectId?: string private readonly _projectId?: string constructor( http: HttpTransport, modelName: string, + projectBasePath: ProjectBasePathBuilder, defaultProjectId?: string, projectIdOverride?: string, ) { this.http = http this.modelName = modelName + this.projectBasePath = projectBasePath this.defaultProjectId = defaultProjectId this._projectId = projectIdOverride } /** per-call 覆盖 projectId,返回新的 ModelHandle 实例 */ project(projectId: string): ModelHandle { - return new ModelHandle(this.http, this.modelName, this.defaultProjectId, projectId) + return new ModelHandle(this.http, this.modelName, this.projectBasePath, this.defaultProjectId, projectId) } /** @@ -98,7 +108,7 @@ export class ModelHandle> { */ query(): FluentQueryBuilder { const pid = this._projectId ?? this.defaultProjectId - return new FluentQueryBuilder(this.http, this.modelName, pid) + return new FluentQueryBuilder(this.http, this.modelName, pid, this.projectBasePath) } // ---- 便捷方法 ---- @@ -263,7 +273,7 @@ export class ModelHandle> { } private basePath(pid: string): string { - return `/api/projects/${pid}/models/${this.modelName}/records` + return `${this.projectBasePath(pid)}/models/${this.modelName}/records` } private buildQueryParams(options?: FindManyOptions): Record { diff --git a/typescript/src/query-builder.ts b/typescript/src/query-builder.ts index 9273be4..e4990ee 100644 --- a/typescript/src/query-builder.ts +++ b/typescript/src/query-builder.ts @@ -15,6 +15,7 @@ import type { HttpTransport } from './http.js' import type { FilterNode, PageDTO, SortItem } from './types.js' +import type {ProjectBasePathBuilder} from './model-handle.js' import { filterEq, filterNe, filterGt, filterGte, filterLt, filterLte, filterIn, filterNin, filterBetween, @@ -42,6 +43,7 @@ export type Operation = 'select' | 'insert' | 'update' | 'merge' | 'delete' | 'c export class FluentQueryBuilder> { private readonly http: HttpTransport private readonly modelName: string + private readonly projectBasePath: ProjectBasePathBuilder private readonly defaultProjectId?: string private _operation: Operation = 'select' @@ -53,9 +55,10 @@ export class FluentQueryBuilder> { private _data?: Partial | Partial[] private _id?: string | number - constructor(http: HttpTransport, modelName: string, defaultProjectId?: string) { + constructor(http: HttpTransport, modelName: string, defaultProjectId: string | undefined, projectBasePath: ProjectBasePathBuilder) { this.http = http this.modelName = modelName + this.projectBasePath = projectBasePath this.defaultProjectId = defaultProjectId } @@ -165,7 +168,7 @@ export class FluentQueryBuilder> { /** 执行查询,根据操作类型返回不同结果 */ async execute(): Promise | TModel | TModel[] | number | void> { const pid = this.resolveProjectId() - const base = `/api/projects/${pid}/models/${this.modelName}/records` + const base = `${this.projectBasePath(pid)}/models/${this.modelName}/records` switch (this._operation) { case 'select': @@ -199,7 +202,7 @@ export class FluentQueryBuilder> { /** 获取单条记录,无匹配时返回 null */ async single(): Promise { const pid = this.resolveProjectId() - const base = `/api/projects/${pid}/models/${this.modelName}/records` + const base = `${this.projectBasePath(pid)}/models/${this.modelName}/records` const result = await this.http.request>('GET', base, { params: { ...this.buildSelectParams(), page: '1', size: '1' }, }) diff --git a/typescript/src/storage-namespace.ts b/typescript/src/storage-namespace.ts new file mode 100644 index 0000000..cc74b89 --- /dev/null +++ b/typescript/src/storage-namespace.ts @@ -0,0 +1,145 @@ +// ============================================================ +// Flexmodel SDK — StorageNamespace (Open API) +// +// Object storage operations: upload, download, list, delete, metadata. +// Maps to backend OpenStorageResource: +// GET /open/{projectId}/buckets/{bucket}/objects +// GET /open/{projectId}/buckets/{bucket}/objects/{path} +// HEAD /open/{projectId}/buckets/{bucket}/objects/{path} +// GET /open/{projectId}/buckets/{bucket}/objects/{path}/metadata +// PUT /open/{projectId}/buckets/{bucket}/objects/{path} +// DELETE /open/{projectId}/buckets/{bucket}/objects/{path} +// ============================================================ + +import type {HttpTransport} from './http.js' +import type {ProjectBasePathBuilder} from './model-handle.js' + +/** 文件元数据 */ +export interface FileItem { + name: string + size?: number + lastModified?: string + + [key: string]: unknown +} + +export class StorageNamespace { + private readonly http: HttpTransport + private readonly projectBasePath: ProjectBasePathBuilder + private defaultProjectId?: string + + constructor(http: HttpTransport, projectBasePath: ProjectBasePathBuilder, defaultProjectId?: string) { + this.http = http + this.projectBasePath = projectBasePath + this.defaultProjectId = defaultProjectId + } + + /** 更新默认 projectId */ + updateDefaultProjectId(projectId?: string): void { + this.defaultProjectId = projectId + } + + private resolveProjectId(projectId?: string): string { + const pid = projectId ?? this.defaultProjectId + if (!pid) { + throw new Error('projectId is required for storage operations. Set it via FlexmodelClient({ projectId }).') + } + return pid + } + + private objectPath(projectId: string, bucketName: string, path?: string): string { + const base = `${this.projectBasePath(projectId)}/buckets/${bucketName}/objects` + return path ? `${base}/${path}` : base + } + + /** + * 列出 bucket 中的对象。 + * + * @example + * const files = await client.storage.list('my-bucket', 'photos/') + */ + async list(bucketName: string, prefix?: string, projectId?: string): Promise { + const pid = this.resolveProjectId(projectId) + const path = this.objectPath(pid, bucketName) + const params: Record = {} + if (prefix) params['prefix'] = prefix + return this.http.request('GET', path, {params}) + } + + /** + * 下载对象,返回 Blob。 + * + * @example + * const blob = await client.storage.download('my-bucket', 'photo.jpg') + */ + async download(bucketName: string, path: string, projectId?: string): Promise { + const pid = this.resolveProjectId(projectId) + const fullPath = this.objectPath(pid, bucketName, path) + const url = this.http.buildUrl(fullPath) + const response = await fetch(url, { + headers: this.http.authHeaders(), + }) + if (!response.ok) { + throw new Error(`Download failed: ${response.status}`) + } + return response.blob() + } + + /** + * 获取对象元数据(HEAD 请求)。 + * + * @example + * const meta = await client.storage.head('my-bucket', 'photo.jpg') + */ + async head(bucketName: string, path: string, projectId?: string): Promise { + const pid = this.resolveProjectId(projectId) + const fullPath = this.objectPath(pid, bucketName, path) + const url = this.http.buildUrl(fullPath) + const response = await fetch(url, { + method: 'HEAD', + headers: this.http.authHeaders(), + }) + if (!response.ok) return null + return { + name: path, + size: response.headers.get('Content-Length') ? Number(response.headers.get('Content-Length')) : undefined, + lastModified: response.headers.get('Last-Modified') ?? undefined, + } + } + + /** + * 获取对象元数据(GET /metadata)。 + * + * @example + * const meta = await client.storage.getMetadata('my-bucket', 'photo.jpg') + */ + async getMetadata(bucketName: string, path: string, projectId?: string): Promise { + const pid = this.resolveProjectId(projectId) + const fullPath = `${this.objectPath(pid, bucketName, path)}/metadata` + return this.http.request('GET', fullPath) + } + + /** + * 上传文件。 + * + * @example + * await client.storage.upload('my-bucket', 'photo.jpg', fileBlob) + */ + async upload(bucketName: string, path: string, body: Blob | ArrayBuffer | string, projectId?: string): Promise { + const pid = this.resolveProjectId(projectId) + const fullPath = this.objectPath(pid, bucketName, path) + await this.http.request('PUT', fullPath, {body, headers: {'content-type': 'application/octet-stream'}}) + } + + /** + * 删除对象。 + * + * @example + * await client.storage.delete('my-bucket', 'photo.jpg') + */ + async delete(bucketName: string, path: string, projectId?: string): Promise { + const pid = this.resolveProjectId(projectId) + const fullPath = this.objectPath(pid, bucketName, path) + await this.http.request('DELETE', fullPath) + } +} diff --git a/typescript/src/types.ts b/typescript/src/types.ts index d02f4ef..97c3e08 100644 --- a/typescript/src/types.ts +++ b/typescript/src/types.ts @@ -10,6 +10,8 @@ export interface FlexmodelClientOptions { apiKey?: string /** 默认项目 ID,数据 API 使用,可在 per-call 时通过 .project() 覆盖 */ projectId?: string + /** 路由模式:path(默认)或 subdomain。subdomain 模式下 projectId 从 Host 提取,路径不含 /open/{projectId} 前缀 */ + routingMode?: 'path' | 'subdomain' } /** 分页响应 DTO,对应后端 PageDTO */ diff --git a/typescript/tests/client.test.ts b/typescript/tests/client.test.ts index 1cfe96c..1884a6c 100644 --- a/typescript/tests/client.test.ts +++ b/typescript/tests/client.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { FlexmodelClient, flexmodelClient, configure, data } from '../src/client' +import { + FlexmodelClient, + FlexmodelAdminClient, + flexmodelClient, + adminClient, + configure, + configureAdmin, + data +} from '../src/client' import { ModelHandle } from '../src/model-handle' import { DataNamespace } from '../src/data-namespace' @@ -8,7 +16,7 @@ function mockFetch(responseBody: unknown = {}, status = 200) { ok: status >= 200 && status < 300, status, json: () => Promise.resolve(responseBody), - statusText: 'OK', + statusText: status === 200 ? 'OK' : 'Error', }) vi.stubGlobal('fetch', mock) return mock @@ -58,7 +66,6 @@ describe('FlexmodelClient: data namespace', () => { const fromHandle = client.data.from('Student') const proxyHandle = (client.data as any).Student expect(proxyHandle).toBeInstanceOf(ModelHandle) - // Proxy 和 from() 应返回同一个缓存的实例 expect(proxyHandle).toBe(fromHandle) }) }) @@ -74,7 +81,7 @@ describe('FlexmodelClient: schema() type narrowing', () => { }) }) -describe('FlexmodelClient: end-to-end data operations', () => { +describe('FlexmodelClient: end-to-end data operations (open path)', () => { it('findMany with filter and pagination', async () => { const fetchMock = mockFetch({ total: 1, @@ -97,7 +104,7 @@ describe('FlexmodelClient: end-to-end data operations', () => { expect(result).toEqual({ total: 1, list: [{ id: 1, name: 'Alice', age: 18 }] }) const url = new URL(fetchMock.mock.calls[0][0]) - expect(url.pathname).toBe('/api/projects/demo/models/Student/records') + expect(url.pathname).toBe('/api/open/demo/models/Student/records') expect(url.searchParams.get('page')).toBe('1') expect(url.searchParams.get('size')).toBe('20') expect(url.searchParams.has('filter')).toBe(true) @@ -224,7 +231,26 @@ describe('FlexmodelClient: project() per-call override', () => { await client.data.from('Student').project('other-project').findMany() const [url] = fetchMock.mock.calls[0] - expect(url).toContain('/projects/other-project/') + expect(url).toContain('/api/open/other-project/') + }) +}) + +describe('FlexmodelClient: subdomain routing mode', () => { + it('uses /api/open/{projectId} prefix in subdomain mode', async () => { + const fetchMock = mockFetch({total: 0, list: []}) + + const client = new FlexmodelClient({ + baseURL: 'https://demo.example.com', + apiKey: 'fm_ak_test', + projectId: 'demo', + routingMode: 'subdomain', + }) + + await client.data.from('Student').findMany() + + const url = new URL(fetchMock.mock.calls[0][0]) + expect(url.pathname).toBe('/api/open/demo/models/Student/records') + expect(url.hostname).toBe('demo.example.com') }) }) @@ -245,6 +271,24 @@ describe('FlexmodelClient: Authorization header', () => { }) }) +describe('FlexmodelClient: functions namespace', () => { + it('invoke sends POST to /api/open/{projectId}/functions/{name}/invoke', async () => { + const fetchMock = mockFetch({result: 'ok'}) + + const client = new FlexmodelClient({ + baseURL: 'http://localhost:8080', + apiKey: 'fm_ak_test', + projectId: 'demo', + }) + + await client.functions.invoke('myFn', {key: 'value'}) + + const url = new URL(fetchMock.mock.calls[0][0]) + expect(url.pathname).toBe('/api/open/demo/functions/myFn/invoke') + expect(fetchMock.mock.calls[0][1].method).toBe('POST') + }) +}) + describe('data export', () => { it('data.Student returns a ModelHandle', () => { configure({ baseURL: 'http://localhost:8080', projectId: 'demo' }) @@ -296,7 +340,7 @@ describe('configure()', () => { await data.from('Student').findMany() const [url] = fetchMock.mock.calls[0] - expect(url).toContain('/projects/configured-project/') + expect(url).toContain('/api/open/configured-project/') }) }) @@ -310,3 +354,86 @@ describe('DataNamespace: schema() type narrowing', () => { expect(typed).toBe(client.data) }) }) + +// ============================================================ +// FlexmodelAdminClient tests +// ============================================================ + +describe('FlexmodelAdminClient: constructor', () => { + it('creates admin client with all options', () => { + const admin = new FlexmodelAdminClient({ + baseURL: 'http://localhost:8080', + apiKey: 'fm_ak_admin_test', + projectId: 'demo', + }) + expect(admin).toBeInstanceOf(FlexmodelAdminClient) + expect(admin.data).toBeDefined() + expect(admin.projects).toBeDefined() + expect(admin.users).toBeDefined() + expect(admin.apiKeys).toBeDefined() + expect(admin.functions).toBeDefined() + }) +}) + +describe('FlexmodelAdminClient: data uses admin path', () => { + it('findMany sends to /api/projects/{projectId}/...', async () => { + const fetchMock = mockFetch({total: 0, list: []}) + + const admin = new FlexmodelAdminClient({ + baseURL: 'http://localhost:8080', + apiKey: 'fm_ak_admin_test', + projectId: 'demo', + }) + + await admin.data.from('Student').findMany() + + const url = new URL(fetchMock.mock.calls[0][0]) + expect(url.pathname).toBe('/api/projects/demo/models/Student/records') + }) +}) + +describe('FlexmodelAdminClient: projects namespace', () => { + it('list sends GET to /api/projects', async () => { + const fetchMock = mockFetch([]) + + const admin = new FlexmodelAdminClient({ + baseURL: 'http://localhost:8080', + apiKey: 'fm_ak_admin_test', + }) + + await admin.projects.list() + + const url = new URL(fetchMock.mock.calls[0][0]) + expect(url.pathname).toBe('/api/projects') + expect(fetchMock.mock.calls[0][1].method).toBe('GET') + }) +}) + +describe('FlexmodelAdminClient: Authorization header', () => { + it('sends Bearer token in header', async () => { + const fetchMock = mockFetch({total: 0, list: []}) + + const admin = new FlexmodelAdminClient({ + baseURL: 'http://localhost:8080', + apiKey: 'fm_ak_admin_test', + projectId: 'demo', + }) + + await admin.data.from('Student').findMany() + + const [, init] = fetchMock.mock.calls[0] + expect(init.headers['Authorization']).toBe('Bearer fm_ak_admin_test') + }) +}) + +describe('configureAdmin()', () => { + it('sets apiKey on admin singleton', async () => { + const fetchMock = mockFetch({total: 0, list: []}) + configureAdmin({baseURL: 'http://localhost:8080', apiKey: 'fm_ak_admin_configured', projectId: 'demo'}) + + await adminClient.data.from('Student').findMany() + + const [, init] = fetchMock.mock.calls[0] + expect(init.headers['Authorization']).toBe('Bearer fm_ak_admin_configured') + }) +}) diff --git a/typescript/tests/data-namespace.test.ts b/typescript/tests/data-namespace.test.ts index a2c1f44..6ab7e3e 100644 --- a/typescript/tests/data-namespace.test.ts +++ b/typescript/tests/data-namespace.test.ts @@ -5,7 +5,7 @@ import { HttpTransport } from '../src/http' function createNamespace(projectId = 'test-project') { const http = new HttpTransport('http://localhost:8080', 'fm_ak_test') - return new DataNamespace(http, projectId) + return new DataNamespace(http, (pid) => `/api/projects/${pid}`, projectId) } beforeEach(() => { diff --git a/typescript/tests/model-handle.test.ts b/typescript/tests/model-handle.test.ts index f5ea589..acb474b 100644 --- a/typescript/tests/model-handle.test.ts +++ b/typescript/tests/model-handle.test.ts @@ -15,7 +15,7 @@ function mockFetch(responseBody: unknown = {}, status = 200) { function createHandle(modelName = 'Student', projectId = 'test-project') { const http = new HttpTransport('http://localhost:8080', 'fm_ak_test123') - return new ModelHandle(http, modelName, projectId) + return new ModelHandle(http, modelName, (pid) => `/api/projects/${pid}`, projectId) } beforeEach(() => { @@ -384,7 +384,7 @@ describe('ModelHandle: missing projectId', () => { it('throws when projectId is not set', async () => { mockFetch({}) const http = new HttpTransport('http://localhost:8080') - const handle = new ModelHandle(http, 'Student') + const handle = new ModelHandle(http, 'Student', (pid) => `/api/projects/${pid}`) await expect(handle.findMany()).rejects.toThrow('projectId is required') }) diff --git a/typescript/tests/query-builder.test.ts b/typescript/tests/query-builder.test.ts index a827bdc..68444a1 100644 --- a/typescript/tests/query-builder.test.ts +++ b/typescript/tests/query-builder.test.ts @@ -15,7 +15,7 @@ function mockFetch(responseBody: unknown = {}, status = 200) { function createBuilder(modelName = 'Student', projectId = 'test-project') { const http = new HttpTransport('http://localhost:8080', 'fm_ak_test123') - return new FluentQueryBuilder(http, modelName, projectId) + return new FluentQueryBuilder(http, modelName, projectId, (pid) => `/api/projects/${pid}`) } beforeEach(() => { @@ -312,7 +312,7 @@ describe('FluentQueryBuilder: via ModelHandle.query()', () => { .execute() const url = new URL(fetchMock.mock.calls[0][0]) - expect(url.pathname).toBe('/api/projects/demo/models/Student/records') + expect(url.pathname).toBe('/api/open/demo/models/Student/records') expect(url.searchParams.get('page')).toBe('1') expect(url.searchParams.get('size')).toBe('20') expect(url.searchParams.get('expand')).toBe('class,teacher')