✨ feat(vscode): 代码阅读加 FastAPI 接口卡片:请求表(URL、方法、路径 / 查询参数、请求头、Cookie、请求体)、按状态码的响应表(成功、responses、函数里抛的异常、自动带的 422);别的文件里的异常状态码、类型别名经工作区索引和语言扩展查
This commit is contained in:
1 parent
1dfc0c7b1e
commit
0798f7d1c7
18 files changed
+1745
-28
No files matched your search
@@ -117,10 +117,14 @@ function ownName(block: CodeBlock): Node | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
/** 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记。 */
|
||||
/**
|
||||
* 用哪些库规则(某 languageId 注册的,按注册顺序;这份源码导入了规则关心的库才启用),规则出错时怎么记,
|
||||
* 规则查名字的定义时交出眼下知道的(RuleContext.lookup,见 ruleLookups.ts);没给时总是没查到。
|
||||
*/
|
||||
export interface RuleRun {
|
||||
rules: readonly LibraryRule[]
|
||||
report: (message: string) => void
|
||||
lookup?: RuleContext['lookup']
|
||||
}
|
||||
|
||||
const NO_RULES: RuleRun = { rules: [], report: () => {} }
|
||||
@@ -142,6 +146,7 @@ export function toBlockViews(blocks: CodeBlock[], document: SourceDocument, tran
|
||||
...(translator && { displayLanguage: translator.displayLanguage }),
|
||||
translate: node => translator ? translator.expression(node, document) : [{ text: node.text, role: 'plain' }],
|
||||
doc: node => translator?.doc(node, document),
|
||||
lookup: rules.lookup ?? (() => undefined),
|
||||
}
|
||||
/** startRow..endRow 这几行整行源码去共同缩进,和它的着色。 */
|
||||
const sourceRows = (root: Node, startRow: number, endRow: number): Pick<BlockView, 'startLine' | 'endLine' | 'code' | 'codeTokens'> => {
|
||||
|
||||
@@ -274,8 +274,9 @@ export type TokenRole
|
||||
* 一段着色的文字。offset:这段照抄自源码里的名字(标识符、属性名、类型名、点分模块名的一节)或字符串时,
|
||||
* 它在源码里的起始下标(同 SourceSpan 的下标),预览拿它向 VS Code 查定义、跳过去;译词、提示词没有。
|
||||
* reference:只有库规则给的段有,这段讲的东西由规则自己查定义(LibraryRule.resolve),不问语言扩展;这样的段必须带 offset。
|
||||
* color:这段自己的颜色(暗色、亮色主题各一个),盖过词性的颜色:库规则按库自己的配色上色(FastAPI 的 HTTP 方法用 Swagger UI 的颜色)。
|
||||
*/
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference }
|
||||
export interface Segment { text: string, role: TokenRole, offset?: number, reference?: RuleReference, color?: ThemeColor }
|
||||
|
||||
/** 框的样式,决定框头的图标和框的颜色:'iteration' 是写在表达式里的遍历,图标、颜色同「循环」块。 */
|
||||
export type GroupStyle = 'iteration'
|
||||
@@ -453,7 +454,7 @@ export interface RuleFacts {
|
||||
absorbs: CodeBlock[]
|
||||
}
|
||||
|
||||
/** 库规则看一块时宿主给的:这份源码、它的导入,以及借当前翻译插件翻表达式、取说明的办法。 */
|
||||
/** 库规则看一块时宿主给的:这份源码、它的导入,借当前翻译插件翻表达式、取说明的办法,和查名字的定义在工作区索引里是哪一项的办法。 */
|
||||
export interface RuleContext {
|
||||
document: SourceDocument
|
||||
/** 这份源码里各处导入的 facts,照源码顺序:规则拿它认出库里名字的别名(`from sqlalchemy import Enum as SQLEnum`)。 */
|
||||
@@ -464,6 +465,13 @@ export interface RuleContext {
|
||||
translate: (node: Node) => Inline[]
|
||||
/** 用当前翻译插件取一段说明文字(Translator.doc);没有翻译插件、没有文字时没有。 */
|
||||
doc: (node: Node) => Markdown | undefined
|
||||
/**
|
||||
* 源码里 name 这个名字(例如 raise 后面的异常类名)指的定义在 index 里认出的那一项,规则从定义处读出的值在 IndexEntry.value 里:
|
||||
* 定义由语言扩展查(同 ⌘ + 点击),落在 index 认出的某一项的名字上才算;同一份源码里同名的名字按一处查。
|
||||
* 认领是同步的,查要等工作区索引建好、语言扩展答话:宿主先当没查到(undefined)交给规则排,发出预览后再查,
|
||||
* 查到的和上次不一样就按新结果重排一遍、重发;文档改了先用上次查到的,再重查。没有工作区时(单元测试)总是 undefined。
|
||||
*/
|
||||
lookup: (index: WorkspaceIndex, name: Node) => IndexEntry | undefined
|
||||
}
|
||||
|
||||
/** 规则自己查得到定义的一个名字:哪个规则(LibraryRule.id),和规则自己认的键(Celery 的任务名)。 */
|
||||
@@ -484,8 +492,11 @@ export interface IndexedFile {
|
||||
exists: (path: string) => boolean
|
||||
}
|
||||
|
||||
/** 规则从一份文件里认出的一项:键,和定义处那个名字在源码里的范围。 */
|
||||
export interface IndexEntry { key: string, name: SourceRange }
|
||||
/**
|
||||
* 规则从一份文件里认出的一项:键,定义处那个名字在源码里的范围,和规则从定义处读出、认领别的文件时要用的值
|
||||
* (FastAPI 规则:异常类的 status_code;见 RuleContext.lookup)。
|
||||
*/
|
||||
export interface IndexEntry { key: string, name: SourceRange, value?: string }
|
||||
|
||||
/**
|
||||
* 规则在整个工作区里建的索引(键 → 定义处):规则只说看哪些文件、从一份源码里认出什么;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { DisplayName } from '../contract'
|
||||
import type { DisplayName, RuleContext } from '../contract'
|
||||
import type { CodeReaderRegistry } from '../registry'
|
||||
import type { TreeSitterRuntime } from '../runtime'
|
||||
import type { DocumentMessage } from './messages'
|
||||
@@ -18,12 +18,16 @@ export interface DocumentRead {
|
||||
displayNames: DisplayName[]
|
||||
}
|
||||
|
||||
/** 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下。 */
|
||||
/**
|
||||
* 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下;
|
||||
* 规则查名字的定义时问 lookup(RuleContext.lookup)。
|
||||
*/
|
||||
export async function documentMessage(
|
||||
document: DocumentSnapshot,
|
||||
registry: Pick<CodeReaderRegistry, 'languageFor' | 'translatorsFor' | 'selectedTranslator' | 'rulesFor'>,
|
||||
runtime: Pick<TreeSitterRuntime, 'parse'>,
|
||||
report: (message: string) => void,
|
||||
lookup?: RuleContext['lookup'],
|
||||
): Promise<DocumentRead> {
|
||||
const { languageId, version } = document
|
||||
const source = document.getText()
|
||||
@@ -42,7 +46,7 @@ export async function documentMessage(
|
||||
if (!adapter)
|
||||
return { message: { ...message, error: `代码阅读还不支持「${languageId}」这种语言` }, displayNames: [] }
|
||||
try {
|
||||
const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report })
|
||||
const { blocks, displayNames } = await readBlockViews(runtime, adapter, translator, languageId, source, { rules: registry.rulesFor(languageId), report, lookup })
|
||||
return { message: { ...message, blocks, attributes: fileAttributes(blocks, translator?.attributeNames) }, displayNames }
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { CustomTextEditorProvider, Disposable, TextDocument, TextDocumentShowOptions, Uri, WebviewPanel } from 'vscode'
|
||||
import type { ClaudeImage } from '../../cc/spawnClaude'
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { RuleLocation, RuleReference } from '../contract'
|
||||
import type { IndexedName, NameSources } from '../nameResolution'
|
||||
import type { RuleLocation, RuleReference, WorkspaceIndex } from '../contract'
|
||||
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
|
||||
import type { CodeQa } from '../qa/codeQa'
|
||||
import type { CodeReaderRegistry } from '../registry'
|
||||
import type { LookupSources } from '../ruleLookups'
|
||||
import type { TreeSitterRuntime } from '../runtime'
|
||||
import type { IndexedEntry } from '../workspaceIndex'
|
||||
import type { BlockFilter } from './blockFilter'
|
||||
import type { Definition, FoundLocation } from './definitions'
|
||||
import type { ExtensionToWebview, Reveal, WebviewToExtension } from './messages'
|
||||
@@ -16,6 +18,7 @@ import { blockReplacement } from '../editText'
|
||||
import { locate } from '../folderStore'
|
||||
import { DocumentNames, resolveNames } from '../nameResolution'
|
||||
import { askPrompt, followUpPrompt } from '../qa/prompt'
|
||||
import { DocumentLookups } from '../ruleLookups'
|
||||
import { definitionAction } from './definitionAction'
|
||||
import { findDefinitions, languageDefinitions, pickDefinition, ruleDefinitions } from './definitions'
|
||||
import { DefinitionSources } from './definitionSources'
|
||||
@@ -50,6 +53,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
|
||||
readonly qa: CodeQa,
|
||||
/** 工作区里别的文件定义的、原名叫 name 的中文名(languageId 是查的这份文档的语言) */
|
||||
readonly indexedNames: (languageId: string, name: string) => Promise<IndexedName[]>,
|
||||
/** 库规则的工作区索引里键是 key 的各项(规则查名字的定义时用,见 ruleLookups.ts) */
|
||||
readonly indexed: (index: WorkspaceIndex, key: string) => Promise<IndexedEntry[]>,
|
||||
) {}
|
||||
|
||||
resolveCustomTextEditor(document: TextDocument, panel: WebviewPanel): void {
|
||||
@@ -124,6 +129,8 @@ class Preview {
|
||||
private sent: { blocks: BlockView[], source: string } | undefined
|
||||
/** 这份文档各版本显示成中文名的名字 */
|
||||
private readonly names: DocumentNames
|
||||
/** 库规则问的名字的定义查到了什么:查到的变了就重发文档 */
|
||||
private readonly lookups: DocumentLookups
|
||||
|
||||
constructor(
|
||||
private readonly document: TextDocument,
|
||||
@@ -146,6 +153,7 @@ class Preview {
|
||||
if (this.ready && version === document.version)
|
||||
this.post({ type: 'displayNames', version, names })
|
||||
})
|
||||
this.lookups = new DocumentLookups(() => this.schedulePush())
|
||||
this.disposables = [
|
||||
panel.webview.onDidReceiveMessage((msg: WebviewToExtension) => this.handleMessage(msg)),
|
||||
panel.onDidChangeViewState(() => {
|
||||
@@ -214,9 +222,10 @@ class Preview {
|
||||
const request = ++this.latest
|
||||
const { languageId, version } = this.document
|
||||
const source = this.document.getText()
|
||||
const session = this.lookups.session()
|
||||
const { message, displayNames } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => {
|
||||
logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
|
||||
})
|
||||
}, session.lookup)
|
||||
if (request !== this.latest || !this.ready)
|
||||
return
|
||||
this.definitions.remember(message.version, message.blocks)
|
||||
@@ -225,8 +234,17 @@ class Preview {
|
||||
this.pendingReveal = undefined
|
||||
this.post({ ...message, names: this.names.known(version), ...(reveal && { reveal }) })
|
||||
// 文档在解析期间又改了的,等下一次发文档再查
|
||||
if (version === this.document.version)
|
||||
if (version === this.document.version) {
|
||||
this.names.resolve(version, report => resolveNames(displayNames, message.blocks, this.nameSources(), report))
|
||||
this.lookups.resolve(version, session.requests(), this.lookupSources())
|
||||
}
|
||||
}
|
||||
|
||||
/** 这份文档里 offset 处名字的定义(语言扩展查的)落在哪儿。 */
|
||||
private async definitionStarts(offset: number): Promise<DefinitionStart[]> {
|
||||
const { document } = this
|
||||
return (await languageDefinitions(document.uri, document.positionAt(offset)))
|
||||
.map(({ uri, range }) => ({ uri: uri.toString(), line: range.start.line, character: range.start.character }))
|
||||
}
|
||||
|
||||
/** 找中文名落在哪些名字上时问的:这份文档的语言扩展、工作区里的中文名索引。 */
|
||||
@@ -235,12 +253,19 @@ class Preview {
|
||||
return {
|
||||
uri: document.uri.toString(),
|
||||
occurrences: async offset => (await occurrencesOf(document, offset)).map(({ start, end }) => ({ start, end })),
|
||||
definitions: async offset => (await languageDefinitions(document.uri, document.positionAt(offset)))
|
||||
.map(({ uri, range }) => ({ uri: uri.toString(), line: range.start.line, character: range.start.character })),
|
||||
definitions: offset => this.definitionStarts(offset),
|
||||
indexed: name => this.owner.indexedNames(document.languageId, name),
|
||||
}
|
||||
}
|
||||
|
||||
/** 查库规则问的名字时问的:规则的工作区索引、这份文档的语言扩展。 */
|
||||
private lookupSources(): LookupSources {
|
||||
return {
|
||||
find: (index, key) => this.owner.indexed(index, key),
|
||||
definitions: offset => this.definitionStarts(offset),
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳到高亮着的名字的下一处 / 上一处出现;页面不在就算了(页面重新加载时高亮已经没了)。 */
|
||||
moveOccurrence(step: 1 | -1): void {
|
||||
if (this.ready)
|
||||
@@ -258,6 +283,7 @@ class Preview {
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.names.dispose()
|
||||
this.lookups.dispose()
|
||||
for (const disposable of this.disposables)
|
||||
disposable.dispose()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""用户管理 console (admin) 路由。
|
||||
|
||||
提供给管理后台用的用户 CRUD、批量开号、批量删除与启用状态切换接口,
|
||||
路径前缀 ``/admin/users``, 全部需要管理员权限。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||
from luffy_billing_sdk.client import BillingClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from luffy_agent.acquisition.service import acquisition_service
|
||||
from luffy_agent.agent.runtime.deps import (
|
||||
get_agent_pool,
|
||||
get_checkpointer,
|
||||
get_session_runner,
|
||||
)
|
||||
from luffy_agent.agent.runtime.pool import AgentPool
|
||||
from luffy_agent.agent.runtime.runner import SessionRunner
|
||||
from luffy_agent.campaign.triggers import emit_user_registered
|
||||
from luffy_agent.core.db import get_db
|
||||
from luffy_agent.core.db.schemas import BulkDeleteFailedItem
|
||||
from luffy_agent.core.exceptions import Forbidden
|
||||
from luffy_agent.core.filter.deps import FilterQuery
|
||||
from luffy_agent.core.params import Int4Path
|
||||
from luffy_agent.deployment.deps import get_billing_client
|
||||
from luffy_agent.user.auth.deps import (
|
||||
ConsolePrincipal,
|
||||
declares_permission,
|
||||
require_console_user,
|
||||
require_permission,
|
||||
)
|
||||
from luffy_agent.user.console.rbac.service import (
|
||||
bind_console_audit_request,
|
||||
console_rbac_service,
|
||||
)
|
||||
from luffy_agent.user.console.schemas import (
|
||||
ImpersonateRequest,
|
||||
ImpersonateResponse,
|
||||
UserBatchCreateItem,
|
||||
UserBatchCreateRequest,
|
||||
UserBatchCreateResponse,
|
||||
UserBulkDeleteRequest,
|
||||
UserBulkDeleteResponse,
|
||||
UserCreateRequest,
|
||||
UserListItem,
|
||||
UserListResponse,
|
||||
UserPhoneBackfillConflictItem,
|
||||
UserPhoneBackfillFilledItem,
|
||||
UserPhoneBackfillResponse,
|
||||
UserToggleActiveResponse,
|
||||
)
|
||||
from luffy_agent.user.models import User, UserRole
|
||||
from luffy_agent.user.schemas import UserDetailResponse, UserUpdateRequest
|
||||
from luffy_agent.user.services import BatchUserResult, user_service
|
||||
|
||||
router = APIRouter(tags=["用户管理-运维"], prefix="/admin/users")
|
||||
|
||||
|
||||
def _assert_permission(principal: ConsolePrincipal, key: str) -> None:
|
||||
if not principal.is_root and key not in principal.permissions:
|
||||
raise Forbidden("无操作权限")
|
||||
|
||||
|
||||
def _reject_admin_role(role: UserRole) -> None:
|
||||
if role == UserRole.ADMIN:
|
||||
raise Forbidden("不能设置管理员角色")
|
||||
|
||||
|
||||
def _to_detail(user: User) -> UserDetailResponse:
|
||||
return UserDetailResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
display_name=user.display_name,
|
||||
role=user.role.value,
|
||||
email=user.email,
|
||||
phone=user.phone,
|
||||
bio=user.bio,
|
||||
avatar_url=user.avatar_url,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@declares_permission("users:activate")
|
||||
async def require_toggle_active_permission(
|
||||
user_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
principal: ConsolePrincipal = Depends(require_console_user),
|
||||
) -> ConsolePrincipal:
|
||||
user = await user_service.get_user(db, user_id=user_id)
|
||||
key = "users:deactivate" if user.is_active else "users:activate"
|
||||
_assert_permission(principal, key)
|
||||
return principal
|
||||
|
||||
|
||||
require_users_view = require_permission("users:view")
|
||||
require_users_create = require_permission("users:create")
|
||||
require_users_batch_create = require_permission("users:batch_create")
|
||||
require_users_update = require_permission("users:update")
|
||||
require_users_delete = require_permission("users:delete")
|
||||
require_users_bulk_delete = require_permission("users:bulk_delete")
|
||||
require_users_impersonate = require_permission("users:impersonate")
|
||||
|
||||
|
||||
@router.get("", response_model=UserListResponse)
|
||||
async def list_users(
|
||||
filters: FilterQuery,
|
||||
page: Annotated[int, Query(ge=1, description="页码")] = 1,
|
||||
page_size: Annotated[int, Query(ge=1, le=100, description="每页数量")] = 20,
|
||||
acquisition_channel_id: Annotated[
|
||||
int | None, Query(description="按来源渠道 ID 精确筛选")
|
||||
] = None,
|
||||
referred: Annotated[
|
||||
Literal["yes", "no"] | None,
|
||||
Query(description="按是否曾被邀请筛选: yes=曾受邀 / no=从未受邀"),
|
||||
] = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: object = Depends(require_users_view),
|
||||
) -> UserListResponse:
|
||||
"""获取用户列表(支持筛选)。
|
||||
|
||||
需要管理员权限。
|
||||
"""
|
||||
skip = (page - 1) * page_size
|
||||
rows, total = await user_service.get_admin_users(
|
||||
db,
|
||||
skip=skip,
|
||||
limit=page_size,
|
||||
filters=filters.filters,
|
||||
acquisition_channel_id=acquisition_channel_id,
|
||||
referred=None if referred is None else referred == "yes",
|
||||
)
|
||||
|
||||
return UserListResponse(
|
||||
items=[
|
||||
UserListItem(
|
||||
id=row.user.id,
|
||||
username=row.user.username,
|
||||
display_name=row.user.display_name,
|
||||
role=row.user.role.value,
|
||||
phone=row.user.phone,
|
||||
is_active=row.user.is_active,
|
||||
acquisition_channel_id=row.channel.id,
|
||||
acquisition_channel_code=row.channel.code,
|
||||
acquisition_channel_name=row.channel.name,
|
||||
referred=row.referred,
|
||||
created_at=row.user.created_at,
|
||||
updated_at=row.user.updated_at,
|
||||
)
|
||||
for row in rows
|
||||
],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=UserDetailResponse, status_code=201)
|
||||
async def create_user(
|
||||
body: UserCreateRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: object = Depends(require_users_create),
|
||||
) -> UserDetailResponse:
|
||||
"""创建新用户。
|
||||
|
||||
需要管理员权限。密码会自动哈希。
|
||||
"""
|
||||
_reject_admin_role(body.role)
|
||||
admin_channel_id = await acquisition_service.resolve_registration_channel_id(
|
||||
db,
|
||||
created_via="password",
|
||||
channel_code=None,
|
||||
admin_created=True,
|
||||
)
|
||||
user = await user_service.create_user(
|
||||
db,
|
||||
username=body.username,
|
||||
password=body.password,
|
||||
display_name=body.display_name,
|
||||
acquisition_channel_id=admin_channel_id,
|
||||
role=body.role,
|
||||
is_active=body.is_active,
|
||||
)
|
||||
emit_user_registered(user.id)
|
||||
|
||||
return _to_detail(user)
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""路由入参的共用类型: 数据库整型主键必须落在 int4 范围内, 越界在入口就 422.
|
||||
|
||||
Python 的 int 不会溢出, 11 位手机号误当 user_id 传进来会一路走到 asyncpg, 绑到
|
||||
INTEGER 列时才抛 DataError, 请求变成 500 且污染当前事务. 主键类路径 / 查询参数
|
||||
一律用这里的类型, 上界与 :data:`luffy_agent.core.db.PG_INT4_MAX` 同源.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Path, Query
|
||||
|
||||
from luffy_agent.core.db import PG_INT4_MAX
|
||||
|
||||
Int4Path = Annotated[int, Path(ge=1, le=PG_INT4_MAX)]
|
||||
"""路径里的整型主键 (``/users/{user_id}`` 一类)."""
|
||||
|
||||
Int4Query = Annotated[int | None, Query(ge=1, le=PG_INT4_MAX)]
|
||||
"""查询串里可选的整型主键过滤 (``?user_id=``)."""
|
||||
@@ -0,0 +1,111 @@
|
||||
"""技能商店用户端路由.
|
||||
|
||||
注: prefix 不带 /api/v1, create_app include 时叠 settings.API_V1_PREFIX.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from luffy_agent.core.db import get_db, get_redis
|
||||
from luffy_agent.sandbox.deps import get_sandbox_registry
|
||||
from luffy_agent.skill_hub import service
|
||||
from luffy_agent.skill_hub.models import HubSkillStatus
|
||||
from luffy_agent.skill_hub.rate_limit import check_mutation_rate
|
||||
from luffy_agent.skill_hub.repositories import hub_skill_repository
|
||||
from luffy_agent.skill_hub.schemas import (
|
||||
AppSkillItem,
|
||||
HubSkillDetail,
|
||||
HubSkillListItem,
|
||||
)
|
||||
from luffy_agent.user.auth.deps import get_current_user
|
||||
from luffy_agent.user.models import User
|
||||
|
||||
skill_hub_router = APIRouter(prefix="/skill-hub", tags=["skill-hub"])
|
||||
|
||||
|
||||
@skill_hub_router.get("/skills", response_model=list[HubSkillListItem])
|
||||
async def list_skills(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_user)],
|
||||
query: Annotated[str | None, Query(max_length=80)] = None,
|
||||
tag: Annotated[str | None, Query(max_length=40)] = None,
|
||||
skip: Annotated[int, Query(ge=0)] = 0,
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 50,
|
||||
) -> list[HubSkillListItem]:
|
||||
rows = await hub_skill_repository.list_published(
|
||||
db, q=query, tag=tag, skip=skip, limit=limit
|
||||
)
|
||||
return [HubSkillListItem.model_validate(row) for row in rows]
|
||||
|
||||
|
||||
@skill_hub_router.get("/skills/{slug}", response_model=HubSkillDetail)
|
||||
async def get_skill(
|
||||
slug: str,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: Annotated[User, Depends(get_current_user)],
|
||||
) -> HubSkillDetail:
|
||||
skill = await hub_skill_repository.get_by_slug(db, slug)
|
||||
if skill is None or skill.status != HubSkillStatus.PUBLISHED:
|
||||
raise HTTPException(status_code=404, detail=f"技能不存在: {slug}")
|
||||
return HubSkillDetail.model_validate(skill)
|
||||
|
||||
|
||||
@skill_hub_router.get("/apps/{app_id}/skills", response_model=list[AppSkillItem])
|
||||
async def list_installed(
|
||||
app_id: str,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> list[AppSkillItem]:
|
||||
rows = await service.list_app_skills(db, user_id=current_user.id, app_id=app_id)
|
||||
return [
|
||||
AppSkillItem(
|
||||
slug=skill.slug,
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
version=record.version,
|
||||
installed_at=record.installed_at,
|
||||
)
|
||||
for record, skill in rows
|
||||
]
|
||||
|
||||
|
||||
@skill_hub_router.post("/apps/{app_id}/skills/{slug}", status_code=204)
|
||||
async def install(
|
||||
app_id: str,
|
||||
slug: str,
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> None:
|
||||
await check_mutation_rate(get_redis(), user_id=current_user.id)
|
||||
registry = get_sandbox_registry(request)
|
||||
await service.install_skill(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
app_id=app_id,
|
||||
slug=slug,
|
||||
sandbox_registry=registry,
|
||||
)
|
||||
|
||||
|
||||
@skill_hub_router.delete("/apps/{app_id}/skills/{slug}", status_code=204)
|
||||
async def uninstall(
|
||||
app_id: str,
|
||||
slug: str,
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> None:
|
||||
await check_mutation_rate(get_redis(), user_id=current_user.id)
|
||||
registry = get_sandbox_registry(request)
|
||||
await service.uninstall_skill(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
app_id=app_id,
|
||||
slug=slug,
|
||||
sandbox_registry=registry,
|
||||
)
|
||||
@@ -21,6 +21,7 @@ import { ReviewMode } from './review/reviewMode'
|
||||
import { celeryRule } from './rules/celery'
|
||||
import { dataclassRule } from './rules/dataclass'
|
||||
import { enumRule } from './rules/enum'
|
||||
import { fastapiRule } from './rules/fastapi'
|
||||
import { sqlalchemyRule } from './rules/sqlalchemy'
|
||||
import { bundledWasmDir, TreeSitterRuntime } from './runtime'
|
||||
import { pythonZhTranslator } from './translators/pythonZh'
|
||||
@@ -73,7 +74,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
const adapter = registry.languageFor(languageId)
|
||||
return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : []
|
||||
}
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn)
|
||||
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key))
|
||||
const notes = new FileNotes()
|
||||
const files = new FileTreeProvider(OPEN_COMMAND, notes)
|
||||
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
|
||||
@@ -116,6 +117,7 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
|
||||
api.registerLibraryRule(celeryRule),
|
||||
api.registerLibraryRule(enumRule),
|
||||
api.registerLibraryRule(dataclassRule),
|
||||
api.registerLibraryRule(fastapiRule),
|
||||
)
|
||||
return api
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { IndexEntry, Node, WorkspaceIndex } from './contract'
|
||||
import type { DefinitionStart } from './nameResolution'
|
||||
import type { LookupSources } from './ruleLookups'
|
||||
import type { IndexedEntry } from './workspaceIndex'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DocumentLookups } from './ruleLookups'
|
||||
|
||||
const INDEX: WorkspaceIndex = { id: 'orm.names', languageId: 'python', include: '**/*.py', mentions: () => true, entries: () => [] }
|
||||
const OTHER: WorkspaceIndex = { ...INDEX, id: 'orm.others' }
|
||||
|
||||
/** 源码里一个名字:只用到原文和下标。 */
|
||||
function nameAt(text: string, startIndex: number): Node {
|
||||
return { text, startIndex } as Node
|
||||
}
|
||||
|
||||
function entry(key: string, value: string, uri: string, line: number): IndexedEntry {
|
||||
const found: IndexEntry = { key, name: { startIndex: 0, endIndex: key.length }, value }
|
||||
return { entry: found, location: { uri, start: { line, character: 6 }, end: { line, character: 6 + key.length } } }
|
||||
}
|
||||
|
||||
/** 工作区索引里的项(按键)和各下标处名字的定义(语言扩展答的);记下问了几次定义。 */
|
||||
function sources(indexed: Record<string, IndexedEntry[]>, definitions: Record<number, DefinitionStart[]>): LookupSources & { asked: number[] } {
|
||||
const asked: number[] = []
|
||||
return {
|
||||
asked,
|
||||
find: async (_index, key) => indexed[key] ?? [],
|
||||
definitions: async (offset) => {
|
||||
asked.push(offset)
|
||||
return definitions[offset] ?? []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('库规则查名字的定义(DocumentLookups)', () => {
|
||||
const FORBIDDEN = entry('Forbidden', '403', 'file:///w/errors.py', 10)
|
||||
const ELSEWHERE = entry('Forbidden', '418', 'file:///w/other.py', 3)
|
||||
const AT_ERRORS = [{ uri: 'file:///w/errors.py', line: 10, character: 6 }]
|
||||
|
||||
it('第一遍当没查到,记下问了什么;查到后叫宿主重排,再排时交出定义处正是的那一项', async () => {
|
||||
let changed = 0
|
||||
const lookups = new DocumentLookups(() => changed++, [])
|
||||
const first = lookups.session()
|
||||
expect(first.lookup(INDEX, nameAt('Forbidden', 40))).toBeUndefined()
|
||||
expect(first.lookup(INDEX, nameAt('Forbidden', 90))).toBeUndefined()
|
||||
expect(first.lookup(OTHER, nameAt('Forbidden', 40))).toBeUndefined()
|
||||
expect(first.requests().map(({ index, key, offset }) => `${index.id}:${key}@${offset}`)).toEqual(['orm.names:Forbidden@40', 'orm.others:Forbidden@40'])
|
||||
|
||||
const from = sources({ Forbidden: [ELSEWHERE, FORBIDDEN] }, { 40: AT_ERRORS })
|
||||
lookups.resolve(1, first.requests(), from)
|
||||
await vi.waitFor(() => expect(changed).toBe(1))
|
||||
expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 12))).toEqual(FORBIDDEN.entry)
|
||||
|
||||
// 同一版本再发一遍:问过的不再查
|
||||
lookups.resolve(1, first.requests(), from)
|
||||
await Promise.resolve()
|
||||
expect(from.asked).toEqual([40, 40])
|
||||
})
|
||||
|
||||
it('索引里没有同名的项不问语言扩展,是没有这个定义;定义处不是索引里的项也算没有;结果没变不叫重排', async () => {
|
||||
let changed = 0
|
||||
const lookups = new DocumentLookups(() => changed++, [])
|
||||
const session = lookups.session()
|
||||
session.lookup(INDEX, nameAt('ValueError', 5))
|
||||
session.lookup(INDEX, nameAt('Forbidden', 40))
|
||||
const from = sources({ Forbidden: [ELSEWHERE] }, { 40: AT_ERRORS })
|
||||
lookups.resolve(1, session.requests(), from)
|
||||
await vi.waitFor(() => expect(from.asked).toEqual([40]))
|
||||
await Promise.resolve()
|
||||
expect(changed).toBe(0)
|
||||
expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 40))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('文档改了先用上一版查到的,新版本再查一遍;查到的变了才叫重排', async () => {
|
||||
let changed = 0
|
||||
const lookups = new DocumentLookups(() => changed++, [])
|
||||
const first = lookups.session()
|
||||
first.lookup(INDEX, nameAt('Forbidden', 40))
|
||||
const indexed: Record<string, IndexedEntry[]> = { Forbidden: [FORBIDDEN] }
|
||||
const from = sources(indexed, { 40: AT_ERRORS, 41: AT_ERRORS })
|
||||
lookups.resolve(1, first.requests(), from)
|
||||
await vi.waitFor(() => expect(changed).toBe(1))
|
||||
|
||||
const second = lookups.session()
|
||||
expect(second.lookup(INDEX, nameAt('Forbidden', 41))?.value).toBe('403')
|
||||
lookups.resolve(2, second.requests(), from)
|
||||
await vi.waitFor(() => expect(from.asked).toEqual([40, 41]))
|
||||
await Promise.resolve()
|
||||
expect(changed).toBe(1)
|
||||
|
||||
indexed.Forbidden = [entry('Forbidden', '401', 'file:///w/errors.py', 10)]
|
||||
const third = lookups.session()
|
||||
third.lookup(INDEX, nameAt('Forbidden', 41))
|
||||
lookups.resolve(3, third.requests(), from)
|
||||
await vi.waitFor(() => expect(changed).toBe(2))
|
||||
expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 41))?.value).toBe('401')
|
||||
})
|
||||
|
||||
it('语言扩展还没起来(问了没答):已知的不动,隔一会儿再查,查到了再叫重排', async () => {
|
||||
let changed = 0
|
||||
const pending: (() => void)[] = []
|
||||
const scheduled: number[] = []
|
||||
const lookups = new DocumentLookups(() => changed++, [5, 10], (task, ms) => {
|
||||
scheduled.push(ms)
|
||||
pending.push(task)
|
||||
return setTimeout(() => {}, 0)
|
||||
})
|
||||
const session = lookups.session()
|
||||
session.lookup(INDEX, nameAt('Forbidden', 40))
|
||||
const definitions: Record<number, DefinitionStart[]> = {}
|
||||
const from = sources({ Forbidden: [FORBIDDEN] }, definitions)
|
||||
lookups.resolve(1, session.requests(), from)
|
||||
await vi.waitFor(() => expect(scheduled).toEqual([5]))
|
||||
expect(changed).toBe(0)
|
||||
|
||||
definitions[40] = AT_ERRORS
|
||||
pending.shift()?.()
|
||||
await vi.waitFor(() => expect(changed).toBe(1))
|
||||
expect(scheduled).toEqual([5])
|
||||
expect(lookups.session().lookup(INDEX, nameAt('Forbidden', 40))?.value).toBe('403')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { IndexEntry, RuleContext, WorkspaceIndex } from './contract'
|
||||
import type { DefinitionStart } from './nameResolution'
|
||||
import type { IndexedEntry } from './workspaceIndex'
|
||||
import { RETRY_DELAYS } from './nameResolution'
|
||||
|
||||
/**
|
||||
* 库规则认领时查的名字(RuleContext.lookup):名字的定义在规则的工作区索引里是哪一项。
|
||||
* 同「中文名」查别的文件里的定义:索引里有同名的项才问语言扩展定义在哪,定义处正是索引里那一项才算。这里不碰 VS Code。
|
||||
*/
|
||||
|
||||
/** 规则问的一个名字:哪个索引、名字原文,和名字在源码里的下标(问语言扩展定义用)。 */
|
||||
export interface LookupRequest { index: WorkspaceIndex, key: string, offset: number }
|
||||
|
||||
/** 查规则问的名字要问的:工作区索引里认出的各项,这份文档里 offset 处名字的定义(语言扩展)。 */
|
||||
export interface LookupSources {
|
||||
find: (index: WorkspaceIndex, key: string) => Promise<IndexedEntry[]>
|
||||
definitions: (offset: number) => Promise<DefinitionStart[]>
|
||||
}
|
||||
|
||||
/** 排一遍视图数据用的:交给规则的 lookup,和这一遍规则问了哪些名字。 */
|
||||
export interface LookupSession {
|
||||
lookup: RuleContext['lookup']
|
||||
requests: () => LookupRequest[]
|
||||
}
|
||||
|
||||
function idOf(index: WorkspaceIndex, key: string): string {
|
||||
return `${index.id}\n${key}`
|
||||
}
|
||||
|
||||
/** 查到的项对规则来说一样不一样:规则只看键和值。 */
|
||||
function same(a: IndexEntry | null | undefined, b: IndexEntry | null | undefined): boolean {
|
||||
return a?.key === b?.key && a?.value === b?.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份打开着的文档里库规则问的名字查到了什么:按「索引 id + 名字原文」记,同一份文档里同名的名字按一处查。
|
||||
* 排视图数据是同步的:先交出已经查到的(文档改了也照旧用上一版查到的),记下这一遍问了什么;发出预览后再查,
|
||||
* 每个版本同一个名字查一次(索引里有同名的项、语言扩展却没答时照 RETRY_DELAYS 隔一会儿再查),查到的和已知的不一样就 changed,
|
||||
* 宿主按新结果重排、重发。
|
||||
*/
|
||||
export class DocumentLookups {
|
||||
/** 查到的:没有这个定义是 null */
|
||||
private readonly known = new Map<string, IndexEntry | null>()
|
||||
private version: number | undefined
|
||||
/** 眼下这个版本查过(或正在查)的名字 */
|
||||
private readonly checked = new Set<string>()
|
||||
private timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
constructor(
|
||||
private readonly changed: () => void,
|
||||
private readonly delays: readonly number[] = RETRY_DELAYS,
|
||||
private readonly schedule: (run: () => void, ms: number) => ReturnType<typeof setTimeout> = setTimeout,
|
||||
) {}
|
||||
|
||||
session(): LookupSession {
|
||||
const requests = new Map<string, LookupRequest>()
|
||||
return {
|
||||
lookup: (index, name) => {
|
||||
const id = idOf(index, name.text)
|
||||
if (!requests.has(id))
|
||||
requests.set(id, { index, key: name.text, offset: name.startIndex })
|
||||
return this.known.get(id) ?? undefined
|
||||
},
|
||||
requests: () => [...requests.values()],
|
||||
}
|
||||
}
|
||||
|
||||
/** 查 version 这一版规则问的名字:这个版本查过的不再查。 */
|
||||
resolve(version: number, requests: readonly LookupRequest[], sources: LookupSources): void {
|
||||
if (this.version !== version) {
|
||||
clearTimeout(this.timer)
|
||||
this.version = version
|
||||
this.checked.clear()
|
||||
}
|
||||
const fresh = requests.filter(request => !this.checked.has(idOf(request.index, request.key)))
|
||||
for (const request of fresh)
|
||||
this.checked.add(idOf(request.index, request.key))
|
||||
if (fresh.length === 0)
|
||||
return
|
||||
const attempt = (tries: number): void => {
|
||||
void this.check(fresh, sources).then(({ settled, changed }) => {
|
||||
if (this.version !== version)
|
||||
return
|
||||
if (changed)
|
||||
this.changed()
|
||||
if (!settled && tries < this.delays.length)
|
||||
this.timer = this.schedule(() => attempt(tries + 1), this.delays[tries])
|
||||
})
|
||||
}
|
||||
attempt(0)
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
clearTimeout(this.timer)
|
||||
this.version = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 查一遍:索引里没有同名的项就是没有这个定义;有就问语言扩展,定义处正是其中一项才算。
|
||||
* 语言扩展没答(还没起来)的名字留着已知的不动,算没查完(settled 为 false)。
|
||||
*/
|
||||
private async check(requests: readonly LookupRequest[], sources: LookupSources): Promise<{ settled: boolean, changed: boolean }> {
|
||||
let settled = true
|
||||
let changed = false
|
||||
await Promise.all(requests.map(async ({ index, key, offset }) => {
|
||||
const id = idOf(index, key)
|
||||
const candidates = await sources.find(index, key)
|
||||
let found: IndexEntry | null = null
|
||||
if (candidates.length > 0) {
|
||||
const definitions = await sources.definitions(offset)
|
||||
if (definitions.length === 0) {
|
||||
settled = false
|
||||
return
|
||||
}
|
||||
found = candidates.find(({ location }) => definitions.some(at => at.uri === location.uri && at.line === location.start.line && at.character === location.start.character))?.entry ?? null
|
||||
}
|
||||
if (!same(this.known.get(id), found))
|
||||
changed = true
|
||||
this.known.set(id, found)
|
||||
}))
|
||||
return { settled, changed }
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ describe('库规则只经 contract 拿信息', () => {
|
||||
const files = readdirSync(__dirname).filter(file => file.endsWith('.ts') && !file.endsWith('.test.ts'))
|
||||
|
||||
it('目录里有规则', () => {
|
||||
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts', 'dataclass.ts']))
|
||||
expect(files).toEqual(expect.arrayContaining(['sqlalchemy.ts', 'celery.ts', 'enum.ts', 'dataclass.ts', 'fastapi.ts']))
|
||||
})
|
||||
|
||||
it.each(files)('%s 不 import 语言插件、翻译插件和宿主', (file) => {
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
import type { BlockView } from '../blockView'
|
||||
import type { Inline, PropertyRow, RuleContext, Segment } from '../contract'
|
||||
import type { DefinitionStart } from '../nameResolution'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { toBlockViews } from '../blockView'
|
||||
import { createPythonAdapter } from '../languages/python'
|
||||
import { DocumentLookups } from '../ruleLookups'
|
||||
import { flatSegments, MemoryIndexHost, propertyLines, readFixture, WASM_DIR, withBlocks } from '../testSupport'
|
||||
import { pythonZhTranslator } from '../translators/pythonZh'
|
||||
import { WorkspaceIndexes } from '../workspaceIndex'
|
||||
import { fastapiRule } from './fastapi'
|
||||
|
||||
const py = createPythonAdapter(WASM_DIR)
|
||||
const ROUTES = readFixture('routes.py')
|
||||
const SKILL_HUB = readFixture('skillHubRoutes.py')
|
||||
const CONSOLE = readFixture('consoleRoutes.py')
|
||||
|
||||
/** 规则查名字的定义时查到的值(名字原文 → IndexEntry.value),别的没查到。 */
|
||||
function known(values: Record<string, string>): RuleContext['lookup'] {
|
||||
return (_index, node) => values[node.text] === undefined ? undefined : { key: node.text, name: { startIndex: 0, endIndex: 0 }, value: values[node.text] }
|
||||
}
|
||||
|
||||
async function views(source: string, lookup?: RuleContext['lookup'], reports: string[] = []): Promise<BlockView[]> {
|
||||
return withBlocks(py, 'python', source, blocks =>
|
||||
toBlockViews(blocks, { source, language: py, markers: [] }, pythonZhTranslator, { rules: [fastapiRule], report: message => reports.push(message), lookup }))
|
||||
}
|
||||
|
||||
function flatten(list: readonly BlockView[]): BlockView[] {
|
||||
return list.flatMap(view => [view, ...flatten(view.children)])
|
||||
}
|
||||
|
||||
function properties(view: BlockView | undefined): PropertyRow[] {
|
||||
if (!view || !('properties' in view))
|
||||
throw new Error(`块 ${view?.id} 不是属性表`)
|
||||
return view.properties
|
||||
}
|
||||
|
||||
/** 函数名是 fn 的接口卡片。 */
|
||||
async function route(source: string, fn: string, lookup?: RuleContext['lookup']): Promise<BlockView> {
|
||||
const found = flatten(await views(source, lookup)).find(view => view.kind === 'fastapi.route' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === fn)
|
||||
if (!found)
|
||||
throw new Error(`没有 ${fn} 的接口卡片`)
|
||||
return found
|
||||
}
|
||||
|
||||
/** 属性表某一行(连子行格子)里所有的词。 */
|
||||
function segmentsOf(row: PropertyRow | undefined): Segment[] {
|
||||
const pieces: Inline[] = [
|
||||
...(!row || 'markdown' in row.value ? [] : row.value),
|
||||
...(row?.items ?? []).flatMap(item => item.cells.flat()),
|
||||
]
|
||||
return flatSegments(pieces)
|
||||
}
|
||||
|
||||
const HEADER = [
|
||||
'from typing import Annotated',
|
||||
'',
|
||||
'from fastapi import APIRouter, Body, Cookie, Depends, FastAPI, File, Form, Header, HTTPException, Path, Query, Request, Security, UploadFile, status',
|
||||
'',
|
||||
'router = APIRouter(prefix="/items", tags=["物品"], dependencies=[Depends(verify_token)], responses={401: {"description": "没登录"}})',
|
||||
'app = FastAPI()',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
describe('fastapi 库规则:接口卡片', () => {
|
||||
it('luffy 的 update_user:名称 / 说明 / 标签 / 异步 / 依赖,请求表、响应表;Forbidden 的状态码查到是 403,四条消息写在一起', async () => {
|
||||
const card = await route(ROUTES, 'update_user', known({ Forbidden: '403' }))
|
||||
expect(card.badge).toEqual({ label: '接口', color: { dark: '#61afef', light: '#1a6fb5' }, icon: 'globe' })
|
||||
expect(propertyLines(properties(card))).toEqual([
|
||||
'名称: update_user',
|
||||
'说明: 更新用户信息。\n\n管理员可更新任意用户,普通用户只能更新自己的基础资料。',
|
||||
'标签: 用户管理',
|
||||
'异步: 是',
|
||||
'依赖: get_db(db)、get_current_user(current_user)',
|
||||
'请求: PUT /users/{user_id}',
|
||||
' URL | /users/{user_id}',
|
||||
' 方法 | PUT',
|
||||
' 路径参数 | user_id: Int4Path',
|
||||
' 请求体 | UserUpdateRequest',
|
||||
'响应: 200 / 403 / 422',
|
||||
' 200 | UserDetailResponse | 成功',
|
||||
' 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码',
|
||||
' 422 | 参数校验失败 | FastAPI 自动返回',
|
||||
])
|
||||
expect(properties(card).map(row => row.columns)).toEqual([undefined, undefined, undefined, undefined, undefined, ['项', '值'], ['状态码', '内容', '说明']])
|
||||
expect(card.attributes).toMatchObject({ kind: 'fastapi.route', access: 'public', async: 'true' })
|
||||
expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('update_user')
|
||||
// 函数体照常是子块,卡片的编辑范围是整个函数(连装饰器)
|
||||
expect(card.children.length).toBeGreaterThan(0)
|
||||
expect(card.code.split('\n')[0]).toBe('@router.put("/{user_id}", response_model=UserDetailResponse)')
|
||||
expect(card.code.split('\n').at(-1)).toBe(' )')
|
||||
})
|
||||
|
||||
it('着色:{参数} 按变量、别的路径按字符串,带源码下标;方法用 Swagger UI 的颜色;成功的状态码绿色,FastAPI 自动带的 422 淡色', async () => {
|
||||
const rows = properties(await route(ROUTES, 'update_user', known({ Forbidden: '403' })))
|
||||
const request = rows.find(row => row.label === '请求')
|
||||
const [url, method] = request?.items ?? []
|
||||
expect(flatSegments(url.cells[1]).map(piece => [piece.text, piece.role])).toEqual([['/users', 'string'], ['/', 'string'], ['{user_id}', 'variable']])
|
||||
for (const piece of flatSegments(url.cells[1]))
|
||||
expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text)
|
||||
expect(method.cells[1]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }])
|
||||
const [ok, forbidden, validation] = rows.find(row => row.label === '响应')?.items ?? []
|
||||
expect(ok.cells[0]).toEqual([{ text: '200', role: 'number', color: { dark: '#2ea043', light: '#1a7f37' } }])
|
||||
const model = ROUTES.indexOf('response_model=UserDetailResponse', ROUTES.indexOf('@router.put')) + 'response_model='.length
|
||||
expect(ok.cells[1]).toEqual([{ text: 'UserDetailResponse', role: 'type', offset: model }])
|
||||
expect(forbidden.cells[0]).toEqual([{ text: '403', role: 'string' }])
|
||||
expect(flatSegments(forbidden.cells[1])).toEqual([{ text: 'Forbidden', role: 'type', offset: ROUTES.indexOf('Forbidden("无权更新') }])
|
||||
expect(flatSegments([...validation.cells[1], ...validation.cells[2]]).map(piece => piece.role)).toEqual(['hint', 'hint'])
|
||||
// 名字都带下标、指回源码原文
|
||||
for (const piece of rows.flatMap(segmentsOf).filter(piece => piece.offset !== undefined && piece.role !== 'string' && piece.role !== 'variable'))
|
||||
expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text)
|
||||
})
|
||||
|
||||
it('没查到异常类的状态码(索引还没建好、语言扩展没答)时不列它', async () => {
|
||||
const lines = propertyLines(properties(await route(ROUTES, 'update_user')))
|
||||
expect(lines.slice(-3)).toEqual(['响应: 200 / 422', ' 200 | UserDetailResponse | 成功', ' 422 | 参数校验失败 | FastAPI 自动返回'])
|
||||
})
|
||||
|
||||
it('luffy 的 list_skills:Annotated 里的 Query 是查询参数(类型取 Annotated 的第一项),Depends 是依赖,参数名是 _ 的只写依赖', async () => {
|
||||
expect(propertyLines(properties(await route(SKILL_HUB, 'list_skills')))).toEqual([
|
||||
'名称: list_skills',
|
||||
'标签: skill-hub',
|
||||
'异步: 是',
|
||||
'依赖: get_db(db)、get_current_user',
|
||||
'请求: GET /skill-hub/skills',
|
||||
' URL | /skill-hub/skills',
|
||||
' 方法 | GET',
|
||||
' 查询参数 | query: 字符串 | 空, tag: 字符串 | 空, skip: 整数, limit: 整数',
|
||||
'响应: 200 / 422',
|
||||
' 200 | list[HubSkillListItem] | 成功',
|
||||
' 422 | 参数校验失败 | FastAPI 自动返回',
|
||||
])
|
||||
})
|
||||
|
||||
it('luffy 的 skill_hub:HTTPException 按 status_code 归,消息是 detail;Request 不算参数;-> None 没有内容', async () => {
|
||||
expect(propertyLines(properties(await route(SKILL_HUB, 'get_skill'))).slice(-3)).toEqual([
|
||||
' 200 | HubSkillDetail | 成功',
|
||||
' 404 | HTTPException | f"技能不存在: {slug}"',
|
||||
' 422 | 参数校验失败 | FastAPI 自动返回',
|
||||
])
|
||||
expect(propertyLines(properties(await route(SKILL_HUB, 'install'))).slice(4)).toEqual([
|
||||
'请求: POST /skill-hub/apps/{app_id}/skills/{slug}',
|
||||
' URL | /skill-hub/apps/{app_id}/skills/{slug}',
|
||||
' 方法 | POST',
|
||||
' 路径参数 | app_id: 字符串, slug: 字符串',
|
||||
'响应: 204 / 422',
|
||||
' 204 | | 成功',
|
||||
' 422 | 参数校验失败 | FastAPI 自动返回',
|
||||
])
|
||||
})
|
||||
|
||||
it('luffy 的 create_user:status_code=201 是成功那一行;Pydantic 模型是请求体;函数里调别的函数抛的异常不算', async () => {
|
||||
expect(propertyLines(properties(await route(CONSOLE, 'create_user', known({ Forbidden: '403' }))))).toEqual([
|
||||
'名称: create_user',
|
||||
'说明: 创建新用户。\n\n需要管理员权限。密码会自动哈希。',
|
||||
'标签: 用户管理-运维',
|
||||
'异步: 是',
|
||||
'依赖: get_db(db)、require_users_create',
|
||||
'请求: POST /admin/users',
|
||||
' URL | /admin/users',
|
||||
' 方法 | POST',
|
||||
' 请求体 | UserCreateRequest',
|
||||
'响应: 201 / 422',
|
||||
' 201 | UserDetailResponse | 成功',
|
||||
' 422 | 参数校验失败 | FastAPI 自动返回',
|
||||
])
|
||||
})
|
||||
|
||||
it('luffy 的 list_users:别处写好的类型别名按索引查到的标记放(FilterQuery 是 Depends → 依赖),Literal 是简单类型', async () => {
|
||||
const lines = propertyLines(properties(await route(CONSOLE, 'list_users', known({ FilterQuery: 'Depends' }))))
|
||||
expect(lines).toContain('依赖: FilterQuery(filters)、get_db(db)、require_users_view')
|
||||
expect(lines).toContain(' 查询参数 | page: 整数, page_size: 整数, acquisition_channel_id: 整数 | 空, referred: Literal["yes", "no"] | 空')
|
||||
expect(lines.some(line => line.includes('请求体'))).toBe(false)
|
||||
})
|
||||
|
||||
it('参数放在哪儿照 FastAPI 的规则:路径里的名字、Query / Header / Cookie / Body / Form / File、上传文件、简单类型、枚举和类型别名(查到的)、Pydantic 模型;Request 不算', async () => {
|
||||
const source = `${HEADER}
|
||||
@router.post("/{item_id}/files/{path:path}")
|
||||
async def upload(
|
||||
item_id: int,
|
||||
path: str,
|
||||
request: Request,
|
||||
extra: Extra,
|
||||
q: str | None = None,
|
||||
ids: list[int] = Query(default=[]),
|
||||
color: Color = Color.RED,
|
||||
owner: Int4Query = None,
|
||||
token: Annotated[str, Header()] = "",
|
||||
session: str = Cookie(None),
|
||||
note: str = Form(...),
|
||||
upload: UploadFile = File(...),
|
||||
item: Item = Body(...),
|
||||
user: User = Security(get_user, scopes=["me"]),
|
||||
helper: Helper = Depends(),
|
||||
):
|
||||
pass
|
||||
`
|
||||
const lines = propertyLines(properties(await route(source, 'upload', known({ Color: 'Enum', Int4Query: 'Query' }))))
|
||||
expect(lines).toEqual(expect.arrayContaining([
|
||||
'依赖: verify_token、get_user(user)、Helper(helper)',
|
||||
' 路径参数 | item_id: 整数, path: 字符串',
|
||||
' 查询参数 | q: 字符串 | 空, ids: 列表[整数], color: Color, owner: Int4Query',
|
||||
' 请求头 | token: 字符串',
|
||||
' Cookie | session: 字符串',
|
||||
' 请求体 | extra: Extra, note: 字符串, upload: UploadFile, item: Item',
|
||||
]))
|
||||
expect(lines.indexOf(' 请求头 | token: 字符串')).toBeLessThan(lines.indexOf(' Cookie | session: 字符串'))
|
||||
})
|
||||
|
||||
it('标签、依赖、响应叠上路由对象的;responses= 各占一行;status 常量、响应描述;嵌套函数里、光写 raise 的不算,raise ... from 算', async () => {
|
||||
const source = `${HEADER}
|
||||
@router.delete(
|
||||
"/{item_id}",
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
response_model=None,
|
||||
response_description="已受理",
|
||||
tags=["管理"],
|
||||
dependencies=[Depends(audit)],
|
||||
responses={404: {"model": ErrorOut, "description": "没找到"}, "5XX": {"description": "服务出错"}},
|
||||
)
|
||||
def remove(item_id: int):
|
||||
"""删掉一个物品。"""
|
||||
try:
|
||||
drop(item_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "物品不存在") from exc
|
||||
except RuntimeError:
|
||||
raise
|
||||
if item_id < 0:
|
||||
raise HTTPException(status_code=409, detail="冲突")
|
||||
|
||||
def inner():
|
||||
raise HTTPException(status_code=500, detail="不算")
|
||||
`
|
||||
expect(propertyLines(properties(await route(source, 'remove')))).toEqual([
|
||||
'名称: remove',
|
||||
'说明: 删掉一个物品。',
|
||||
'标签: 物品、管理',
|
||||
'异步: 否',
|
||||
'依赖: verify_token、audit',
|
||||
'请求: DELETE /items/{item_id}',
|
||||
' URL | /items/{item_id}',
|
||||
' 方法 | DELETE',
|
||||
' 路径参数 | item_id: 整数',
|
||||
'响应: 202 / 401 / 404 / 409 / 422 / 5XX',
|
||||
' 202 | | 已受理',
|
||||
' 401 | | 没登录',
|
||||
' 404 | ErrorOut、HTTPException | 没找到;物品不存在',
|
||||
' 409 | HTTPException | 冲突',
|
||||
' 422 | 参数校验失败 | FastAPI 自动返回',
|
||||
' 5XX | | 服务出错',
|
||||
])
|
||||
})
|
||||
|
||||
it('直接挂在 FastAPI 应用上的接口:没有前缀;没有参数时没有 422;别的装饰器照写', async () => {
|
||||
const source = `${HEADER}
|
||||
@app.get("/health")
|
||||
@cache(60)
|
||||
async def health() -> dict:
|
||||
return {"ok": True}
|
||||
`
|
||||
expect(propertyLines(properties(await route(source, 'health')))).toEqual([
|
||||
'名称: health',
|
||||
'异步: 是',
|
||||
'装饰器: @cache(60)',
|
||||
'请求: GET /health',
|
||||
' URL | /health',
|
||||
' 方法 | GET',
|
||||
'响应: 200',
|
||||
' 200 | 字典 | 成功',
|
||||
])
|
||||
})
|
||||
|
||||
it('不认的:没导入 fastapi、路由对象不在这份文件里、不是路由对象的方法、不是挂接口的方法', async () => {
|
||||
const plain = 'router = APIRouter()\n\n@router.get("/")\ndef a():\n pass\n'
|
||||
expect(flatten(await views(plain)).some(view => view.kind === 'fastapi.route')).toBe(false)
|
||||
const source = `${HEADER}
|
||||
from .routes import other_router
|
||||
|
||||
@other_router.get("/")
|
||||
def b():
|
||||
pass
|
||||
|
||||
@limiter.get("/")
|
||||
def c():
|
||||
pass
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def d(ws):
|
||||
pass
|
||||
|
||||
@router.get
|
||||
def e():
|
||||
pass
|
||||
`
|
||||
const reports: string[] = []
|
||||
expect(flatten(await views(source, undefined, reports)).filter(view => view.kind === 'fastapi.route')).toEqual([])
|
||||
expect(reports).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fastapi 库规则:别的文件里的定义(工作区索引 + 语言扩展查定义)', () => {
|
||||
const SCHEMAS = 'backend/src/luffy_agent/core/exceptions/schemas.py'
|
||||
const PARAMS = 'backend/src/luffy_agent/core/params.py'
|
||||
const files: Record<string, string> = {
|
||||
[SCHEMAS]: readFixture('appExceptions.py'),
|
||||
[PARAMS]: readFixture('coreParams.py'),
|
||||
'backend/src/luffy_agent/user/routes.py': ROUTES,
|
||||
'backend/src/luffy_agent/other/errors.py': 'class Forbidden(Exception):\n status_code = 418\n',
|
||||
'backend/src/luffy_agent/other/models.py': 'import enum\n\nclass Color(str, enum.Enum):\n RED = "red"\n\nclass Plain:\n pass\n',
|
||||
}
|
||||
|
||||
/** file 里 text 的起点(行、列 0 起),语言扩展答的定义处。 */
|
||||
function at(file: string, text: string): DefinitionStart {
|
||||
const before = files[file].slice(0, files[file].indexOf(text)).split('\n')
|
||||
return { uri: `${MemoryIndexHost.FOLDER}/${file}`, line: before.length - 1, character: before.at(-1)?.length ?? 0 }
|
||||
}
|
||||
|
||||
it('update_user 抛的 Forbidden:语言扩展查到定义在 core/exceptions/schemas.py,那个类上 status_code = 403;同名的别处的类不算', async () => {
|
||||
const indexes = new WorkspaceIndexes(new MemoryIndexHost(files, py))
|
||||
const definitions = async (offset: number): Promise<DefinitionStart[]> => ROUTES.startsWith('Forbidden', offset) ? [at(SCHEMAS, 'Forbidden(AppException)')] : []
|
||||
let changed = 0
|
||||
const lookups = new DocumentLookups(() => changed++, [])
|
||||
const first = lookups.session()
|
||||
expect(propertyLines(properties(await route(ROUTES, 'update_user', first.lookup)))).not.toContain(' 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码')
|
||||
|
||||
lookups.resolve(1, first.requests(), { find: (index, key) => indexes.find(index, key), definitions })
|
||||
await vi.waitFor(() => expect(changed).toBe(1))
|
||||
expect(propertyLines(properties(await route(ROUTES, 'update_user', lookups.session().lookup)))).toContain(' 403 | Forbidden | 无权更新此用户信息;无权修改角色;无权修改启用状态;无权修改密码')
|
||||
indexes.dispose()
|
||||
})
|
||||
|
||||
it('索引认的:类上的 status_code(带不带注解、status 常量)、枚举类、Annotated 类型别名的标记;普通类不认', async () => {
|
||||
const host = new MemoryIndexHost({
|
||||
...files,
|
||||
'backend/src/luffy_agent/other/more.py': 'from fastapi import status\n\nclass Gone(Exception):\n status_code: int = status.HTTP_410_GONE\n',
|
||||
}, py)
|
||||
const indexes = new WorkspaceIndexes(host)
|
||||
const lookups = new DocumentLookups(() => {}, [])
|
||||
const session = lookups.session()
|
||||
await route(`${HEADER}
|
||||
@router.get("/{item_id}")
|
||||
def show(item_id: Int4Path, color: Color, owner: Int4Query, plain: Plain):
|
||||
raise Gone("没了")
|
||||
`, 'show', session.lookup)
|
||||
const index = session.requests()[0].index
|
||||
const values = async (key: string): Promise<string[]> => (await indexes.find(index, key)).map(({ entry }) => `${entry.value}`)
|
||||
expect(await values('Forbidden')).toEqual(['403', '418'])
|
||||
expect(await values('Gone')).toEqual(['410'])
|
||||
expect(await values('Color')).toEqual(['Enum'])
|
||||
expect(await values('Int4Path')).toEqual(['Path'])
|
||||
expect(await values('Int4Query')).toEqual(['Query'])
|
||||
expect(await values('Plain')).toEqual([])
|
||||
expect(host.reports).toEqual([])
|
||||
indexes.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,625 @@
|
||||
import type { FunctionBlock, ImportFacts, IndexEntry, Inline, LibraryRule, Node, ParameterFacts, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, WorkspaceIndex } from '../contract'
|
||||
import type { Call, LibraryNames } from './pythonTree'
|
||||
import { callOf, genericOf, lastName, libraryName, stringContent, syntaxChildren, unwrapType } from './pythonTree'
|
||||
import { hint, joined, name, range, word } from './segments'
|
||||
|
||||
/**
|
||||
* FastAPI 库规则:挂在路由对象上的函数(`@router.get(...)` 这类装饰器,router 是同一文件模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)`
|
||||
* 赋值出来的)整块换成「接口」卡片,函数体照常是子块。卡片:名称 / 说明 / 标签 / 异步 / 依赖,然后请求表(一项一行)、响应表(一个状态码一行)。
|
||||
* 参数放在请求的哪儿照 FastAPI 的规则认(见 placeOf);函数体里直接抛的异常按状态码归进响应表:HTTPException 看 status_code,
|
||||
* 自定义的异常类看类上的 `status_code = N`,类写在别处时经工作区索引查(NAMES_INDEX,RuleContext.lookup)。
|
||||
* 挂到应用上时再叠的前缀(include_router、settings.API_V1_PREFIX)在别的文件里,URL 不算。用词都在词表里,按翻译插件的显示语言挑。
|
||||
*/
|
||||
|
||||
const RULE_ID = 'fastapi'
|
||||
const KIND = 'fastapi.route'
|
||||
const COLOR: ThemeColor = { dark: '#61afef', light: '#1a6fb5' }
|
||||
const ICON = 'globe'
|
||||
/** 认路由对象、参数标记、HTTPException 时看从这几个库导入的名字(HTTPException、status 也可以从 starlette 来)。 */
|
||||
const NAME_LIBRARIES = ['fastapi', 'starlette']
|
||||
|
||||
/** 路由对象上挂接口的方法:`router.get`、`app.post` …… */
|
||||
const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'trace'])
|
||||
/** HTTP 方法的颜色:Swagger UI 的配色,看接口文档的人最熟;白底上照原色看不清,亮色主题取同色相深一些的。 */
|
||||
const METHOD_COLORS: Record<string, ThemeColor> = {
|
||||
GET: { dark: '#61affe', light: '#1b6ac9' },
|
||||
POST: { dark: '#49cc90', light: '#18834f' },
|
||||
PUT: { dark: '#fca130', light: '#b35f00' },
|
||||
PATCH: { dark: '#50e3c2', light: '#0f8a70' },
|
||||
DELETE: { dark: '#f93e3e', light: '#cf2020' },
|
||||
HEAD: { dark: '#b36bff', light: '#9012fe' },
|
||||
OPTIONS: { dark: '#4b92e0', light: '#0d5aa7' },
|
||||
}
|
||||
/** 成功(小于 400)的状态码的颜色。 */
|
||||
const SUCCESS_COLOR: ThemeColor = { dark: '#2ea043', light: '#1a7f37' }
|
||||
|
||||
/** 参数上写的 FastAPI 标记(默认值 `= Query(...)`,或 `Annotated[T, Query(...)]` 里的)→ 放在请求的哪儿。 */
|
||||
const MARKER_PLACES: Record<string, Place> = {
|
||||
Path: 'path',
|
||||
Query: 'query',
|
||||
Header: 'header',
|
||||
Cookie: 'cookie',
|
||||
Body: 'body',
|
||||
Form: 'body',
|
||||
File: 'body',
|
||||
Depends: 'dependency',
|
||||
Security: 'dependency',
|
||||
}
|
||||
/** FastAPI 自己往里填、不是请求带来的参数类型。 */
|
||||
const INJECTED = new Set(['Request', 'Response', 'WebSocket', 'HTTPConnection', 'BackgroundTasks', 'SecurityScopes'])
|
||||
/** 简单类型:没写标记时是查询参数;别的类(Pydantic 模型)是请求体。 */
|
||||
const SIMPLE = new Set(['int', 'float', 'str', 'bool', 'bytes', 'complex', 'UUID', 'datetime', 'date', 'time', 'timedelta', 'Decimal', 'Any', 'EmailStr', 'HttpUrl', 'AnyUrl', 'SecretStr'])
|
||||
/** 包着别的类型的写法:里面都是简单类型时它也是(`list[str]`、`Optional[int]`)。 */
|
||||
const WRAPPERS = new Set(['Optional', 'Union', 'list', 'List', 'set', 'Set', 'frozenset', 'FrozenSet', 'tuple', 'Tuple', 'Sequence'])
|
||||
/** enum 库的枚举类型:父类是它们的类是简单类型(查询参数)。 */
|
||||
const ENUM_BASES = new Set(['Enum', 'IntEnum', 'StrEnum', 'Flag', 'IntFlag'])
|
||||
/** 类属性、状态码常量里的状态码:`status_code = 403`、`status.HTTP_403_FORBIDDEN`。 */
|
||||
const STATUS_NAME = /^HTTP_(\d{3})_/
|
||||
|
||||
type Place = 'path' | 'query' | 'header' | 'cookie' | 'body' | 'dependency'
|
||||
|
||||
/** 一种显示语言的用词。 */
|
||||
interface Words {
|
||||
kind: string
|
||||
labels: Record<'name' | 'doc' | 'tags' | 'async' | 'decorators' | 'dependencies' | 'request' | 'responses', string>
|
||||
yes: string
|
||||
no: string
|
||||
requestHeads: string[]
|
||||
responseHeads: string[]
|
||||
items: Record<'url' | 'method' | 'path' | 'query' | 'header' | 'cookie' | 'body', string>
|
||||
/** 成功那一行没写 response_description 时的说明 */
|
||||
success: string
|
||||
/** FastAPI 自动带的 422:内容、说明 */
|
||||
validation: string
|
||||
automatic: string
|
||||
/** 几项之间(标签、依赖、内容)、几条消息之间 */
|
||||
listSeparator: string
|
||||
messageSeparator: string
|
||||
/** 依赖注进哪个参数:「(param)」 */
|
||||
injectedInto: (param: string) => string
|
||||
}
|
||||
|
||||
const ZH: Words = {
|
||||
kind: '接口',
|
||||
labels: { name: '名称', doc: '说明', tags: '标签', async: '异步', decorators: '装饰器', dependencies: '依赖', request: '请求', responses: '响应' },
|
||||
yes: '是',
|
||||
no: '否',
|
||||
requestHeads: ['项', '值'],
|
||||
responseHeads: ['状态码', '内容', '说明'],
|
||||
items: { url: 'URL', method: '方法', path: '路径参数', query: '查询参数', header: '请求头', cookie: 'Cookie', body: '请求体' },
|
||||
success: '成功',
|
||||
validation: '参数校验失败',
|
||||
automatic: 'FastAPI 自动返回',
|
||||
listSeparator: '、',
|
||||
messageSeparator: ';',
|
||||
injectedInto: param => `(${param})`,
|
||||
}
|
||||
|
||||
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
|
||||
const WORDS: Record<string, Words> = { zh: ZH }
|
||||
|
||||
interface Env {
|
||||
context: RuleContext
|
||||
words: Words
|
||||
/** fastapi、starlette 和从它们导入的名字 */
|
||||
names: LibraryNames
|
||||
}
|
||||
|
||||
// ─── 别的文件里的定义 ─────────────────────────────────────────────────────────
|
||||
|
||||
/** 状态码写法 → 三位数字:整数字面量、`HTTP_403_FORBIDDEN` 这种常量(`status.` 开头也算)。 */
|
||||
function statusNumber(node: Node | null | undefined): string | undefined {
|
||||
if (node?.type === 'integer')
|
||||
return node.text
|
||||
const match = STATUS_NAME.exec(lastName(node) ?? '')
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
/** 类主体里 `status_code = N`(带不带注解)写的状态码。 */
|
||||
function classStatus(definition: Node): string | undefined {
|
||||
for (const statement of syntaxChildren(definition.childForFieldName('body'))) {
|
||||
const [assignment] = statement.type === 'expression_statement' ? syntaxChildren(statement) : []
|
||||
if (assignment?.type === 'assignment' && assignment.childForFieldName('left')?.text === 'status_code') {
|
||||
const status = statusNumber(assignment.childForFieldName('right'))
|
||||
if (status)
|
||||
return status
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* `Annotated[T, ..., Query(...)]`:T,和里面的 FastAPI 标记(调用)。markerName 认调用的是哪个标记:
|
||||
* 本文件按导入认(fastapi 里的名字),别的文件(建索引时)只看名字的最后一节。
|
||||
*/
|
||||
function annotatedMarker(node: Node | null | undefined, markerName: (call: Node) => string | undefined): { type?: Node, marker?: Node } | undefined {
|
||||
const generic = node ? genericOf(node) : undefined
|
||||
if (!generic || lastName(generic.head) !== 'Annotated')
|
||||
return undefined
|
||||
const [type, ...metadata] = generic.args.map(unwrapType)
|
||||
return { type, marker: metadata.find(item => MARKER_PLACES[markerName(item) ?? ''] !== undefined) }
|
||||
}
|
||||
|
||||
/** 调用的函数名的最后一节(建索引时认别的文件里的标记)。 */
|
||||
function calleeName(node: Node): string | undefined {
|
||||
return node.type === 'call' ? lastName(node.childForFieldName('function')) : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 从一份文件的模块顶层认出别的文件要查的名字,值是:
|
||||
* - 类上写了 `status_code = N` 的(异常类):状态码 N;
|
||||
* - 父类是 enum 枚举类型的类:`Enum`(简单类型,查询参数);
|
||||
* - `X = Annotated[T, Query(...)]` 这种类型别名:标记名(`Query`、`Path`、`Depends` ……)。
|
||||
*/
|
||||
function indexEntries(root: Node): IndexEntry[] {
|
||||
const entries: IndexEntry[] = []
|
||||
for (const statement of syntaxChildren(root)) {
|
||||
const definition = statement.type === 'decorated_definition' ? statement.childForFieldName('definition') : statement
|
||||
if (definition?.type === 'class_definition') {
|
||||
const className = definition.childForFieldName('name')
|
||||
const status = classStatus(definition)
|
||||
const enumType = syntaxChildren(definition.childForFieldName('superclasses')).some(base => ENUM_BASES.has(lastName(base) ?? ''))
|
||||
const value = status ?? (enumType ? 'Enum' : undefined)
|
||||
if (className && value)
|
||||
entries.push({ key: className.text, name: range(className), value })
|
||||
continue
|
||||
}
|
||||
const [assignment] = statement.type === 'expression_statement' ? syntaxChildren(statement) : []
|
||||
const target = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
|
||||
const marker = annotatedMarker(assignment?.childForFieldName('right'), calleeName)?.marker
|
||||
if (target?.type === 'identifier' && marker)
|
||||
entries.push({ key: target.text, name: range(target), value: calleeName(marker) })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
const NAMES_INDEX: WorkspaceIndex = {
|
||||
id: `${RULE_ID}.names`,
|
||||
languageId: 'python',
|
||||
include: '**/*.py',
|
||||
mentions: source => /\bstatus_code\b|\bAnnotated\b|Enum\b/.test(source),
|
||||
entries: root => indexEntries(root),
|
||||
}
|
||||
|
||||
/** 名字(`Forbidden`、`exceptions.Forbidden` 的最后一节)的定义在索引里认出的值;没查到是 undefined。 */
|
||||
function definedValue(node: Node | null | undefined, env: Env): string | undefined {
|
||||
const target = node?.type === 'attribute' ? node.childForFieldName('attribute') : node
|
||||
return target?.type === 'identifier' ? env.context.lookup(NAMES_INDEX, target)?.value : undefined
|
||||
}
|
||||
|
||||
// ─── 读语法树 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** fastapi、starlette(连子模块)自己和从它们导入的名字:本地名 → 原名。 */
|
||||
function namesOf(imports: readonly ImportFacts[]): LibraryNames {
|
||||
const names: LibraryNames = { modules: new Set(), imported: new Map() }
|
||||
const ours = (module: string): boolean => NAME_LIBRARIES.some(library => module === library || module.startsWith(`${library}.`))
|
||||
for (const facts of imports) {
|
||||
const from = facts.sources.some(source => ours(source.text))
|
||||
for (const item of facts.items) {
|
||||
if (item.kind === 'module' && ours(item.module.text))
|
||||
names.modules.add(item.alias?.text ?? item.module.text)
|
||||
else if (item.kind === 'named' && from)
|
||||
names.imported.set(item.alias?.text ?? item.name.text, item.name.text)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/** 调用的是 fastapi / starlette 里的哪个名字(`Query(...)`、`fastapi.APIRouter(...)`)。 */
|
||||
function calledName(node: Node | null | undefined, env: Env): string | undefined {
|
||||
return node?.type === 'call' ? libraryName(node.childForFieldName('function'), env.names) : undefined
|
||||
}
|
||||
|
||||
/** 列表、元组的各项;别的写法当一项。 */
|
||||
function listItems(node: Node | undefined): Node[] {
|
||||
if (!node)
|
||||
return []
|
||||
return node.type === 'list' || node.type === 'tuple' ? syntaxChildren(node) : [node]
|
||||
}
|
||||
|
||||
/** 路由对象:同一文件模块顶层 `X = APIRouter(...)` / `X = FastAPI(...)` 赋值出来的,和它给挂在上面的接口叠的东西。 */
|
||||
interface Router {
|
||||
prefix?: Node
|
||||
tags: Node[]
|
||||
dependencies: Node[]
|
||||
responses?: Node
|
||||
}
|
||||
|
||||
function routersOf(root: Node, env: Env): Map<string, Router> {
|
||||
const routers = new Map<string, Router>()
|
||||
for (const statement of syntaxChildren(root)) {
|
||||
const [assignment] = statement.type === 'expression_statement' ? syntaxChildren(statement) : []
|
||||
const target = assignment?.type === 'assignment' ? assignment.childForFieldName('left') : null
|
||||
const value = assignment?.childForFieldName('right')
|
||||
const kind = calledName(value, env)
|
||||
const call = callOf(value, new Map())
|
||||
if (target?.type !== 'identifier' || !call || (kind !== 'APIRouter' && kind !== 'FastAPI'))
|
||||
continue
|
||||
routers.set(target.text, {
|
||||
prefix: kind === 'APIRouter' ? call.keywords.get('prefix') : undefined,
|
||||
tags: listItems(call.keywords.get('tags')),
|
||||
dependencies: listItems(call.keywords.get('dependencies')),
|
||||
responses: call.keywords.get('responses'),
|
||||
})
|
||||
}
|
||||
return routers
|
||||
}
|
||||
|
||||
/** 挂在路由对象上的一个接口:`@router.put("/{user_id}", ...)`。 */
|
||||
interface Route {
|
||||
decorator: Node
|
||||
router: Router
|
||||
method: string
|
||||
call: Call
|
||||
path?: Node
|
||||
}
|
||||
|
||||
function routeOf(decorator: Node, routers: ReadonlyMap<string, Router>): Route | undefined {
|
||||
const [expression] = syntaxChildren(decorator)
|
||||
const callee = expression?.type === 'call' ? expression.childForFieldName('function') : null
|
||||
const object = callee?.type === 'attribute' ? callee.childForFieldName('object') : null
|
||||
const method = callee?.childForFieldName('attribute')?.text
|
||||
const router = object?.type === 'identifier' ? routers.get(object.text) : undefined
|
||||
const call = callOf(expression, new Map())
|
||||
if (!router || !call || !method || !METHODS.has(method))
|
||||
return undefined
|
||||
return { decorator, router, method: method.toUpperCase(), call, path: call.positional[0] ?? call.keywords.get('path') }
|
||||
}
|
||||
|
||||
/** 路径里的一段:`{参数}` 按变量着色,其余按字符串,带源码下标;写的不是字符串时交给翻译插件翻。 */
|
||||
function pathSegments(node: Node | undefined, env: Env): Inline[] {
|
||||
if (!node)
|
||||
return []
|
||||
const content = stringContent(node)
|
||||
if (!content)
|
||||
return env.context.translate(node)
|
||||
const segments: Segment[] = []
|
||||
let at = content.node?.startIndex ?? node.startIndex
|
||||
for (const part of content.text.split(/(\{[^}]*\})/).filter(Boolean)) {
|
||||
segments.push({ text: part, role: part.startsWith('{') ? 'variable' : 'string', offset: at })
|
||||
at += part.length
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
/** 路径里的参数名:`{user_id}`、`{file_path:path}` 里冒号前面的。 */
|
||||
function pathNames(parts: readonly (Node | undefined)[]): Set<string> {
|
||||
const text = parts.map(part => stringContent(part)?.text ?? '').join('')
|
||||
return new Set([...text.matchAll(/\{([^}:]+)(?::[^}]*)?\}/g)].map(match => match[1].trim()))
|
||||
}
|
||||
|
||||
/** 一个参数放在请求的哪儿;FastAPI 自己往里填的(Request……)、`*args` 这种没有。 */
|
||||
interface Placed {
|
||||
place: Place
|
||||
param: Node
|
||||
/** 参数的类型(`Annotated[T, ...]` 取 T);没标注的没有 */
|
||||
type?: Node
|
||||
/** 依赖:Depends(x) 的 x */
|
||||
dependency?: Node
|
||||
}
|
||||
|
||||
/** 类型最外层的名字:`int`、`list[str]` 的 list、`sa.UUID` 的 UUID。 */
|
||||
function headName(type: Node): string | undefined {
|
||||
return lastName(genericOf(type)?.head ?? unwrapType(type))
|
||||
}
|
||||
|
||||
/** 简单类型(FastAPI 当查询参数的):内置的标量、UUID、日期时间、Literal,枚举类(别处定义的经索引查),和只包着它们的 Optional / list / `X | None`。 */
|
||||
function isSimple(node: Node, env: Env): boolean {
|
||||
const type = unwrapType(node)
|
||||
if (type.type === 'none')
|
||||
return true
|
||||
if (type.type === 'binary_operator')
|
||||
return syntaxChildren(type).every(side => isSimple(side, env))
|
||||
const generic = genericOf(type)
|
||||
if (generic) {
|
||||
const head = lastName(generic.head)
|
||||
if (head === 'Literal')
|
||||
return true
|
||||
if (head === 'Annotated')
|
||||
return generic.args.length > 0 && isSimple(generic.args[0], env)
|
||||
return head !== undefined && WRAPPERS.has(head) && generic.args.every(arg => arg.type === 'ellipsis' || isSimple(arg, env))
|
||||
}
|
||||
const head = lastName(type)
|
||||
return head !== undefined && (SIMPLE.has(head) || definedValue(type, env) === 'Enum')
|
||||
}
|
||||
|
||||
/** 上传文件(`UploadFile`、`list[UploadFile]`):FastAPI 当表单里的文件,是请求体。 */
|
||||
function isUpload(type: Node): boolean {
|
||||
const generic = genericOf(type)
|
||||
return headName(type) === 'UploadFile' || (generic !== undefined && WRAPPERS.has(lastName(generic.head) ?? '') && generic.args.some(isUpload))
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数放在请求的哪儿,照 FastAPI 的规则:Depends / Security 是依赖;名字在路径里是路径参数;写了 Query / Header / Cookie / Body / Form / File
|
||||
* 照写的(默认值 `= Query(...)` 或 `Annotated[T, Query(...)]`,别处写好的 `X = Annotated[T, Query(...)]` 经索引查);
|
||||
* Request 这些 FastAPI 自己填的不算;上传文件、别的类(Pydantic 模型)是请求体;其余简单类型、没标注的是查询参数。
|
||||
*/
|
||||
function placeOf(parameter: ParameterFacts, inPath: ReadonlySet<string>, env: Env): Placed | undefined {
|
||||
const param = parameter.name
|
||||
if (param.type !== 'identifier')
|
||||
return undefined
|
||||
const annotated = annotatedMarker(parameter.type, call => calledName(call, env))
|
||||
const type = annotated?.type ?? (parameter.type && unwrapType(parameter.type))
|
||||
const markerCall = MARKER_PLACES[calledName(parameter.default, env) ?? ''] ? parameter.default : annotated?.marker
|
||||
const marker = markerCall && calledName(markerCall, env)
|
||||
if (markerCall && marker && MARKER_PLACES[marker] === 'dependency') {
|
||||
const call = callOf(markerCall, new Map())
|
||||
return { place: 'dependency', param, dependency: call?.positional[0] ?? call?.keywords.get('dependency') ?? type }
|
||||
}
|
||||
if (inPath.has(param.text))
|
||||
return { place: 'path', param, type }
|
||||
if (marker && MARKER_PLACES[marker])
|
||||
return { place: MARKER_PLACES[marker], param, type }
|
||||
const alias = type && (type.type === 'identifier' || type.type === 'attribute') ? definedValue(type, env) : undefined
|
||||
if (alias && MARKER_PLACES[alias])
|
||||
return MARKER_PLACES[alias] === 'dependency' ? { place: 'dependency', param, dependency: type } : { place: MARKER_PLACES[alias], param, type }
|
||||
if (type && INJECTED.has(headName(type) ?? ''))
|
||||
return undefined
|
||||
if (type && isUpload(type))
|
||||
return { place: 'body', param, type }
|
||||
return !type || isSimple(type, env) ? { place: 'query', param, type } : { place: 'body', param, type }
|
||||
}
|
||||
|
||||
/** 函数体里直接抛的异常(嵌套的函数、类、lambda 里的不算)。 */
|
||||
function raisesIn(node: Node | null | undefined): Node[] {
|
||||
const found: Node[] = []
|
||||
const visit = (current: Node): void => {
|
||||
for (const child of syntaxChildren(current)) {
|
||||
if (child.type === 'raise_statement')
|
||||
found.push(child)
|
||||
else if (!['function_definition', 'class_definition', 'decorated_definition', 'lambda'].includes(child.type))
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
if (node)
|
||||
visit(node)
|
||||
return found
|
||||
}
|
||||
|
||||
// ─── 写成属性表 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** 一串词拼成的文字(框写框头)。 */
|
||||
function textOf(pieces: readonly Inline[]): string {
|
||||
return pieces.map(piece => 'role' in piece ? piece.text : piece.label).join('')
|
||||
}
|
||||
|
||||
/** 依赖的名字:写成一个名字时按函数着色,别的交给翻译插件翻。 */
|
||||
function dependencyText(node: Node, env: Env): Inline[] {
|
||||
return node.type === 'identifier' ? [name(node, 'function')] : env.context.translate(node)
|
||||
}
|
||||
|
||||
/** `dependencies=[Depends(x), ...]` 里的各个 x。 */
|
||||
function listedDependencies(items: readonly Node[], env: Env): Inline[][] {
|
||||
return items.flatMap((item) => {
|
||||
const call = MARKER_PLACES[calledName(item, env) ?? ''] === 'dependency' ? callOf(item, new Map()) : undefined
|
||||
const dependency = call?.positional[0] ?? call?.keywords.get('dependency')
|
||||
return dependency ? [dependencyText(dependency, env)] : []
|
||||
})
|
||||
}
|
||||
|
||||
/** 标签:字符串照写(不带引号,带下标),别的交给翻译插件翻。 */
|
||||
function tagText(node: Node, env: Env): Inline[] {
|
||||
const content = stringContent(node)
|
||||
return content?.node ? [{ text: content.text, role: 'string', offset: content.node.startIndex }] : env.context.translate(node)
|
||||
}
|
||||
|
||||
/** 「名称: 类型」一串,逗号隔开;没标注的只写名称。 */
|
||||
function paramsText(placed: readonly Placed[], env: Env): Inline[] {
|
||||
return joined(placed.map(({ param, type }) => [name(param, 'variable'), ...(type ? [{ text: ': ', role: 'plain' as const }, ...env.context.translate(type)] : [])]), { text: ', ', role: 'plain' })
|
||||
}
|
||||
|
||||
/** 响应表里一个状态码的一行:状态码、内容、说明,同一个状态码的几处写在一起。 */
|
||||
interface Status {
|
||||
code: Inline[]
|
||||
/** 三位数字的状态码按大小排,别的(`"4XX"`、`default`)排在后面 */
|
||||
order: number
|
||||
contents: Inline[][]
|
||||
notes: Inline[][]
|
||||
}
|
||||
|
||||
/** 响应表里的状态码:三位数字的(按大小排、上色),或照写的(`"5XX"`、交给翻译插件翻的表达式)。 */
|
||||
type StatusCode = { key: string, number: number } | { key: string, cell: Inline[] }
|
||||
|
||||
function numericStatus(code: string): StatusCode {
|
||||
return { key: code, number: Number(code) }
|
||||
}
|
||||
|
||||
/** 状态码写法 → 状态码:`403`、`status.HTTP_403_FORBIDDEN`、`"403"` 是数字,别的字符串(`"5XX"`)照写,别的写法交给翻译插件翻。 */
|
||||
function statusCodeOf(node: Node, env: Env): StatusCode {
|
||||
const text = stringContent(node)?.text
|
||||
const number = statusNumber(node) ?? (text !== undefined && /^\d{3}$/.test(text) ? text : undefined)
|
||||
if (number)
|
||||
return numericStatus(number)
|
||||
return text !== undefined ? { key: text, cell: [{ text, role: 'string' }] } : { key: node.text, cell: env.context.translate(node) }
|
||||
}
|
||||
|
||||
/** 响应表的各行:一个状态码一行,同一个状态码的几处写进同一行(内容、说明各自去重)。 */
|
||||
class Statuses {
|
||||
private readonly byKey = new Map<string, Status>()
|
||||
|
||||
add(status: StatusCode, content?: Inline[], note?: Inline[]): void {
|
||||
let row = this.byKey.get(status.key)
|
||||
if (!row) {
|
||||
const code: Inline[] = 'cell' in status
|
||||
? status.cell
|
||||
: [status.number < 400 ? { text: status.key, role: 'number', color: SUCCESS_COLOR } : { text: status.key, role: 'string' }]
|
||||
row = { code, order: 'number' in status ? status.number : Number.POSITIVE_INFINITY, contents: [], notes: [] }
|
||||
this.byKey.set(status.key, row)
|
||||
}
|
||||
if (content && content.length > 0 && !row.contents.some(known => textOf(known) === textOf(content)))
|
||||
row.contents.push(content)
|
||||
if (note && note.length > 0 && !row.notes.some(known => textOf(known) === textOf(note)))
|
||||
row.notes.push(note)
|
||||
}
|
||||
|
||||
rows(): Status[] {
|
||||
return [...this.byKey.values()].sort((a, b) => a.order - b.order)
|
||||
}
|
||||
}
|
||||
|
||||
/** `responses={404: {"model": X, "description": "…"}, ...}` 的各项:模型是内容,description 是说明;值不是字典时整个是内容。 */
|
||||
function addResponses(node: Node | undefined, statuses: Statuses, env: Env): void {
|
||||
if (node?.type !== 'dictionary')
|
||||
return
|
||||
for (const pair of syntaxChildren(node)) {
|
||||
const key = pair.type === 'pair' ? pair.childForFieldName('key') : null
|
||||
const value = pair.childForFieldName('value')
|
||||
if (!key || !value)
|
||||
continue
|
||||
if (value.type !== 'dictionary') {
|
||||
statuses.add(statusCodeOf(key, env), env.context.translate(value))
|
||||
continue
|
||||
}
|
||||
const fields = new Map(syntaxChildren(value).flatMap((field) => {
|
||||
const fieldKey = field.type === 'pair' ? stringContent(field.childForFieldName('key'))?.text : undefined
|
||||
const fieldValue = field.childForFieldName('value')
|
||||
return fieldKey !== undefined && fieldValue ? [[fieldKey, fieldValue] as const] : []
|
||||
}))
|
||||
const model = fields.get('model')
|
||||
const description = stringContent(fields.get('description'))?.text
|
||||
statuses.add(statusCodeOf(key, env), model && modelText(model, env), description ? [{ text: description, role: 'comment' }] : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写在参数值里的响应模型(response_model=、responses 里的 "model"):写成一个名字时按类型着色(翻译插件翻表达式时把它当变量),
|
||||
* 别的交给翻译插件翻。返回类型是注解,翻译插件本来就按类型翻。
|
||||
*/
|
||||
function modelText(node: Node, env: Env): Inline[] {
|
||||
return node.type === 'identifier' ? [name(node, 'type')] : env.context.translate(node)
|
||||
}
|
||||
|
||||
/** 抛出时带的消息:字符串照写(说明色),别的交给翻译插件翻。 */
|
||||
function messageText(node: Node | undefined, env: Env): Inline[] | undefined {
|
||||
if (!node)
|
||||
return undefined
|
||||
const content = stringContent(node)
|
||||
return content ? [{ text: content.text, role: 'comment' }] : env.context.translate(node)
|
||||
}
|
||||
|
||||
/** 函数体里直接抛的异常按状态码归进去:HTTPException 看 status_code、detail;别的类看索引里查到的类上的 status_code,消息是第一个参数(或 message=、detail=)。 */
|
||||
function addRaises(body: Node | null | undefined, statuses: Statuses, env: Env): void {
|
||||
for (const raise of raisesIn(body)) {
|
||||
const [thrown] = syntaxChildren(raise)
|
||||
if (!thrown)
|
||||
continue
|
||||
const callee = thrown.type === 'call' ? thrown.childForFieldName('function') : thrown
|
||||
const call = callOf(thrown, new Map())
|
||||
const className = callee?.type === 'attribute' ? callee.childForFieldName('attribute') : callee
|
||||
if (className?.type !== 'identifier')
|
||||
continue
|
||||
if (libraryName(callee, env.names) === 'HTTPException') {
|
||||
const status = call?.keywords.get('status_code') ?? call?.positional[0]
|
||||
if (status)
|
||||
statuses.add(statusCodeOf(status, env), [name(className, 'type')], messageText(call?.keywords.get('detail') ?? call?.positional[1], env))
|
||||
continue
|
||||
}
|
||||
const status = definedValue(className, env)
|
||||
if (status && /^\d{3}$/.test(status))
|
||||
statuses.add(numericStatus(status), [name(className, 'type')], messageText(call?.positional[0] ?? call?.keywords.get('message') ?? call?.keywords.get('detail'), env))
|
||||
}
|
||||
}
|
||||
|
||||
/** 请求表的一行:项 | 值。 */
|
||||
function requestItem(label: string, value: Inline[]): PropertyItem {
|
||||
return { cells: [[hint(label)], value] }
|
||||
}
|
||||
|
||||
function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
|
||||
const { context, words } = env
|
||||
const { labels } = words
|
||||
const { facts } = block
|
||||
const { router, call } = route
|
||||
const separator = hint(words.listSeparator)
|
||||
|
||||
const inPath = pathNames([router.prefix, route.path])
|
||||
const placed = facts.parameters.flatMap(parameter => placeOf(parameter, inPath, env) ?? [])
|
||||
const at = (place: Place): Placed[] => placed.filter(item => item.place === place)
|
||||
const body = at('body')
|
||||
|
||||
const url = [...pathSegments(router.prefix, env), ...pathSegments(route.path, env)]
|
||||
const request: PropertyItem[] = [
|
||||
requestItem(words.items.url, url),
|
||||
requestItem(words.items.method, [{ text: route.method, role: 'keyword', ...(METHOD_COLORS[route.method] && { color: METHOD_COLORS[route.method] }) }]),
|
||||
...(['path', 'query', 'header', 'cookie'] as const).flatMap(place => at(place).length > 0 ? [requestItem(words.items[place], paramsText(at(place), env))] : []),
|
||||
...(body.length > 0 ? [requestItem(words.items.body, body.length === 1 && body[0].type ? context.translate(body[0].type) : paramsText(body, env))] : []),
|
||||
]
|
||||
|
||||
const statuses = new Statuses()
|
||||
const success = call.keywords.get('status_code')
|
||||
const model = call.keywords.get('response_model')
|
||||
const description = stringContent(call.keywords.get('response_description'))?.text
|
||||
// 没写 response_model 时内容是返回类型;`-> None` 没有内容
|
||||
const returned = facts.returnType && unwrapType(facts.returnType).type !== 'none' ? facts.returnType : undefined
|
||||
statuses.add(
|
||||
success ? statusCodeOf(success, env) : numericStatus('200'),
|
||||
model ? (model.type === 'none' ? undefined : modelText(model, env)) : returned && context.translate(returned),
|
||||
[{ text: description ?? words.success, role: 'comment' }],
|
||||
)
|
||||
addResponses(router.responses, statuses, env)
|
||||
addResponses(call.keywords.get('responses'), statuses, env)
|
||||
const definition = block.node.type === 'decorated_definition' ? block.node.childForFieldName('definition') : block.node
|
||||
addRaises(definition?.childForFieldName('body'), statuses, env)
|
||||
if (placed.some(item => item.place !== 'dependency'))
|
||||
statuses.add(numericStatus('422'), [hint(words.validation)], [hint(words.automatic)])
|
||||
const responses = statuses.rows()
|
||||
|
||||
const doc = facts.doc && context.doc(facts.doc)
|
||||
const tags = [...router.tags, ...listItems(call.keywords.get('tags'))].map(tag => tagText(tag, env))
|
||||
const decorators = facts.decorators.filter(other => other !== route.decorator)
|
||||
const dependencies = [
|
||||
...listedDependencies(router.dependencies, env),
|
||||
...listedDependencies(listItems(call.keywords.get('dependencies')), env),
|
||||
...at('dependency').flatMap(({ param, dependency }) => dependency
|
||||
? [[...dependencyText(dependency, env), ...(param.text === '_' ? [] : [hint(words.injectedInto(param.text))])]]
|
||||
: []),
|
||||
]
|
||||
const rows: PropertyRow[] = [
|
||||
...(facts.name ? [{ label: labels.name, value: [name(facts.name, 'function')] }] : []),
|
||||
...(doc ? [{ label: labels.doc, value: doc }] : []),
|
||||
...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []),
|
||||
{ label: labels.async, value: [word(facts.async ? words.yes : words.no)] },
|
||||
...(decorators.length > 0 ? [{ label: labels.decorators, value: joined(decorators.map(other => context.translate(other)), { text: '\n', role: 'plain' }) }] : []),
|
||||
...(dependencies.length > 0 ? [{ label: labels.dependencies, value: joined(dependencies, separator) }] : []),
|
||||
{
|
||||
label: labels.request,
|
||||
value: [hint(`${route.method} ${textOf(url)}`)],
|
||||
columns: words.requestHeads,
|
||||
items: request,
|
||||
},
|
||||
{
|
||||
label: labels.responses,
|
||||
value: [hint(responses.map(row => textOf(row.code)).join(' / '))],
|
||||
columns: words.responseHeads,
|
||||
items: responses.map(row => ({
|
||||
cells: [row.code, joined(row.contents, separator), joined(row.notes, { text: words.messageSeparator, role: 'comment' })],
|
||||
})),
|
||||
},
|
||||
]
|
||||
return {
|
||||
kind: KIND,
|
||||
badge: { label: words.kind, color: COLOR, icon: ICON },
|
||||
rows,
|
||||
...(facts.name && { defines: range(facts.name) }),
|
||||
absorbs: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const fastapiRule: LibraryRule = {
|
||||
id: RULE_ID,
|
||||
label: 'FastAPI 接口',
|
||||
languageIds: ['python'],
|
||||
libraries: ['fastapi'],
|
||||
claim: (block, context) => {
|
||||
if (block.kind !== 'function' || block.facts.decorators.length === 0)
|
||||
return undefined
|
||||
const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, names: namesOf(context.imports) }
|
||||
const routers = routersOf(block.node.tree.rootNode, env)
|
||||
for (const decorator of block.facts.decorators) {
|
||||
const route = routeOf(decorator, routers)
|
||||
if (route)
|
||||
return routeCard(block, route, env)
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
Reference in new issue
Block a user