import { OPS } from './ops' export interface ApiCallConfig { pathParams?: Record params?: Record data?: unknown headers?: Record signal?: AbortSignal } interface RawResponse { status: number data: unknown headers?: Headers } export interface ApiEnvelopeShape { code: number message: string data?: T | null error?: string | null error_code?: string | null request_id?: string } function text(value: unknown): string { if (typeof value === 'string') return value if (value && typeof value === 'object') { const body = value as Record for (const key of ['error', 'message']) { const candidate = body[key] if (typeof candidate === 'string' && candidate.trim()) return candidate } } return '' } function envelope(response: RawResponse): ApiEnvelopeShape { const body = response.data if (body && typeof body === 'object') { const record = body as Record if (typeof record.code === 'number') return record as unknown as ApiEnvelopeShape } const status = response.status return { code: status, message: text(body) || (status >= 500 ? '服务未响应(网关错误)' : status >= 400 ? '服务返回了非接口数据' : ''), data: null, error: null, error_code: null, } } function fail(error: unknown): never { if (error instanceof DOMException && error.name === 'AbortError') throw error const wrapped = new Error(error instanceof Error ? error.message : 'network request failed') wrapped.name = 'NetworkError' throw wrapped } function query(params: Record | undefined): Record | undefined { if (!params) return undefined const out: Record = {} for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null && value !== '') out[key] = value } return Object.keys(out).length ? out : undefined } function normalize(key: string): string { return key.toLowerCase().replace(/[-_]/g, '') } const ALIASES: Record = { 'space.listspaces': 'space.space_list', 'space.createspace': 'space.space_create', 'space.getspace': 'space.space_get', 'space.updatespace': 'space.space_update', 'space.deletespace': 'space.space_delete', 'space.restorespace': 'space.space_restore', 'space.listspacemembers': 'space.space_member_list', 'space.addmember': 'space.space_member_add', 'space.getmember': 'space.space_member_get', 'space.updatemember': 'space.space_member_update', 'space.updateselfmember': 'space.space_member_update_self', 'space.removemember': 'space.space_member_remove', 'space.listbans': 'space.space_ban_list', 'space.getban': 'space.space_ban_get', 'space.updateban': 'space.space_ban_update', 'space.unbanuser': 'space.space_ban_delete', 'space.listinvites': 'space.space_invite_list', 'space.createinvite': 'space.space_invite_create', 'space.revokeinvite': 'space.space_invite_revoke', 'remotes.fetch': 'remotes.fetch_remote', 'tag_protection.listpolicies': 'tag_protection.list_tag_policies', 'tag_protection.createpolicy': 'tag_protection.create_tag_policy', 'tag_protection.getpolicy': 'tag_protection.get_tag_policy', 'tag_protection.updatepolicy': 'tag_protection.update_tag_policy', 'tag_protection.deletepolicy': 'tag_protection.delete_tag_policy', } const RESOLVED = new Map() for (const [key, entry] of Object.entries(OPS)) { const table = RESOLVED as Map table.set(`${normalize(key)}`, { key, entry }) } for (const [from, to] of Object.entries(ALIASES)) { const hit = RESOLVED.get(normalize(to)) if (hit) RESOLVED.set(normalize(from), hit) } export const MISSING_OPS = new Set() async function invoke(tag: string, op: string, config: ApiCallConfig = {}): Promise { const lookup = RESOLVED.get(`${normalize(tag)}.${normalize(op)}`) if (!lookup) { MISSING_OPS.add(`${tag}.${op}`) return { code: 501, message: `未提供接口:${tag}.${op}`, data: null, error: null, error_code: 'not_implemented', } } const { entry } = lookup const names = Object.keys(config.pathParams ?? {}).slice(0, entry.path) const args: unknown[] = [] if (entry.path > 0 && names.length !== entry.path) { return { code: 400, message: `缺少路径参数:${tag}.${op}`, data: null, error: null, error_code: 'bad_request', } } // The generated URL builders interpolate path values verbatim, so a branch, // tag or file path containing `/` would split the route into extra segments // (404 "unknown api route"). Percent-encode here, once, for every operation. for (const name of names) { const value = config.pathParams?.[name] args.push(encodeURIComponent(String(value ?? ''))) } const queryArgs = entry.query ? query(config.params) : undefined if (entry.bodyFirst) { if (entry.body) args.push(config.data) if (entry.query) args.push(queryArgs) } else { if (entry.query) args.push(queryArgs) if (entry.body) args.push(config.data) } const options: RequestInit = { credentials: 'same-origin' } if (config.headers) options.headers = config.headers if (config.signal) options.signal = config.signal args.push(options) try { const response = (await entry.fn(...args)) as RawResponse const body = envelope(response) if (response.status >= 400 && !body.error_code && typeof body.error === 'string') { body.error_code = null } return { ...body, code: response.status >= 400 ? response.status : body.code } } catch (error) { fail(error) } } type ProxyFn = (config?: ApiCallConfig) => Promise export const Apis: Record> = new Proxy( {}, { get: (_target, tag: string) => new Proxy( {}, { get: (_scope, op: string) => (config?: ApiCallConfig) => invoke(tag, op, config), }, ), }, ) as Record> export function trackFetch(input: string, init?: RequestInit): Promise { return fetch(input, { credentials: 'same-origin', ...init }) } export function newTraceparent(): string { const hex = (n: number) => Array.from({ length: n }, () => Math.floor(Math.random() * 16).toString(16)).join('') return `00-${hex(32)}-${hex(16)}-01` } export type * from '@kenapi/apiclient/index.schemas' export type { AddMemberBody, BanMemberBody, CreateInviteBody, CreateOrganizationBody, EnvelopeCreateInviteResponse, EnvelopePermissionCheck, InviteSummary, MemberSummary, OrganizationSummary, RedeemInviteBody, SpaceJoinRequestState, SpaceMemberRole, UpdateBanBody, UpdateMemberBody, UpdateOrganizationBody, } from '@kenapi/apiclient/index.schemas' export * from './ops' /** `GET /spaces/{space}/members` page: rows are flat `MemberSummary`s. */ export type MemberSummaryPage = NonNullable< import('@kenapi/apiclient/index.schemas').EnvelopePageMemberSummary['data'] > export type MemberSummaryRow = MemberSummaryPage['items'][number] export type EnvelopeInviteSummary = import('@kenapi/apiclient/index.schemas').EnvelopeCreateInviteResponse export interface RoomMemberRow { user: { user_id: number; username: string; display_name: string; avatar_url: string } user_id?: number username?: string display_name?: string | null avatar_url?: string | null role?: string nick?: string | null role_keys?: string[] joined_at?: string | null muted?: boolean permissions?: string[] } export type EnvelopeVecRoomMemberDto = { data?: RoomMemberRow[] | null } export type CreateInviteCmd = import('@kenapi/apiclient/index.schemas').CreateInviteBody export type UpdateMemberCmd = import('@kenapi/apiclient/index.schemas').UpdateMemberBody export type UpdateSpaceCmd = import('@kenapi/apiclient/index.schemas').UpdateOrganizationBody export type CreateSpaceCmd = import('@kenapi/apiclient/index.schemas').CreateOrganizationBody export type AcceptInviteCmd = import('@kenapi/apiclient/index.schemas').RedeemInviteBody export type AddMemberCmd = import('@kenapi/apiclient/index.schemas').AddMemberBody export type BanUserCmd = import('@kenapi/apiclient/index.schemas').BanMemberBody export type UpdateBanCmd = import('@kenapi/apiclient/index.schemas').UpdateBanBody export type SpaceJoinRequestStatus = import('@kenapi/apiclient/index.schemas').SpaceJoinRequestState export default Apis