✨ feat(vscode): 代码阅读加 FastAPI 接口卡片:请求表(URL、方法、路径 / 查询参数、请求头、Cookie、请求体)、按状态码的响应表(成功、responses、函数里抛的异常、自动带的 422);别的文件里的异常状态码、类型别名经工作区索引和语言扩展查

This commit is contained in:
cruldra committed 2026-09-27 20:19:24 +08:00
1 parent 1dfc0c7b1e
commit 0798f7d1c7
18 files changed
+1745 -28

No files matched your search

+357
View 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()
})
})