Files
superwork/vscode/src/codeReader/rules/fastapi.test.ts
T

488 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, inlineOf, 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.flatMap(inlineOf)),
]
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管理员可更新任意用户,普通用户只能更新自己的基础资料。',
'路由器: router',
'标签: 用户管理',
'异步: 是',
'依赖: 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, undefined, ['项', '值'], ['状态码', '内容', '说明']])
expect(card.attributes).toMatchObject({ kind: 'fastapi.route', access: 'public', async: 'true', httpMethod: 'PUT' })
expect(fastapiRule.attributeNames?.('zh')).toEqual({ attributes: { httpMethod: '请求方法' }, values: {} })
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(inlineOf(url.cells[1])).map(piece => [piece.text, piece.role])).toEqual([['/users', 'string'], ['/', 'string'], ['{', 'variable'], ['user_id', 'variable'], ['}', 'variable']])
for (const piece of flatSegments(inlineOf(url.cells[1])))
expect(ROUTES.slice(piece.offset, (piece.offset ?? 0) + piece.text.length)).toBe(piece.text)
// 路径里的 user_id 讲的是 update_user 的参数 user_id:高亮参数的各处出现时跟着亮,⌘ + 点击跳到参数
const param = ROUTES.indexOf('user_id: Int4Path', ROUTES.indexOf('def update_user'))
expect(flatSegments(inlineOf(url.cells[1])).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: param, endIndex: param + 'user_id'.length })
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(inlineOf(forbidden.cells[1]))).toEqual([{ text: 'Forbidden', role: 'type', offset: ROUTES.indexOf('Forbidden("无权更新') }])
expect(flatSegments([...inlineOf(validation.cells[1]), ...inlineOf(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_router',
'标签: 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(5)).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需要管理员权限。密码会自动哈希。',
'路由器: router',
'标签: 用户管理-运维',
'异步: 是',
'依赖: 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',
'说明: 删掉一个物品。',
'路由器: router',
'标签: 物品、管理',
'异步: 否',
'依赖: 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',
'路由器: app',
'异步: 是',
'装饰器: @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([])
})
})
/** 名字是 name 的路由器卡片。 */
async function routerCard(source: string, name: string): Promise<BlockView> {
const found = flatten(await views(source)).find(view => view.kind === 'fastapi.router' && view.defines && source.slice(view.defines.startIndex, view.defines.endIndex) === name)
if (!found)
throw new Error(`没有 ${name} 的路由器卡片`)
return found
}
describe('fastapi 库规则:说明里的块标签', () => {
const SOURCE = [
'from fastapi import APIRouter, Depends, Query',
'',
'router = APIRouter(prefix="/apps")',
'',
'@router.get("/{app_id}")',
'async def get_app(app_id: int, q: str | None = Query(None), db = Depends(get_db)) -> AppOut:',
' """取一个应用。',
'',
' @param app_id 应用 id',
' @param q 按名字搜',
' @param db 数据库会话',
' @param qq 写错的名字',
' @return 应用详情',
' @throws [AppNotFound] 没有这个应用',
' """',
].join('\n')
it('@param 对到请求表(一个参数一行,多一列说明),依赖参数不算对不上;@return 跟在响应后面;@throws 成异常表', async () => {
const lines = propertyLines(properties(await route(SOURCE, 'get_app')))
expect(lines).toEqual([
'名称: get_app',
'说明: 取一个应用。',
'对不上的参数: 1 个',
' qq | 写错的名字',
'路由器: router',
'异步: 是',
'依赖: get_db(db)',
'请求: GET /apps/{app_id}',
' URL | /apps/{app_id} | ',
' 方法 | GET | ',
' 路径参数 | app_id: 整数 | 应用 id',
' 查询参数 | q: 字符串 | 空 | 按名字搜',
'响应: 200 / 422 — 应用详情',
' 200 | AppOut | 成功',
' 422 | 参数校验失败 | FastAPI 自动返回',
'异常: 1 个',
' [AppNotFound] | 没有这个应用',
])
})
})
describe('fastapi 库规则:路由器卡片', () => {
it('luffy 的 router:名称 / 前缀 / 标签,接口表列这份文件里挂在它上面的接口(方法 / 完整路径 / 函数 / docstring 第一行)', async () => {
const card = await routerCard(ROUTES, 'router')
expect(card.badge).toEqual({ label: '路由器', color: { dark: '#7aa2f7', light: '#34548a' }, icon: 'network' })
expect(propertyLines(properties(card))).toEqual([
'名称: router',
'前缀: /users',
'标签: 用户管理',
'接口: 3 个',
' GET | /users/{user_id} | get_user | 获取用户详情。',
' PUT | /users/{user_id} | update_user | 更新用户信息。',
' POST | /users/me/avatar | upload_current_user_avatar | 上传当前登录用户头像。',
])
expect(properties(card).at(-1)?.columns).toEqual(['方法', '路径', '函数', '说明'])
expect(card.code).toBe('router = APIRouter(tags=["用户管理"], prefix="/users")')
expect(ROUTES.slice(card.defines?.startIndex, card.defines?.endIndex)).toBe('router')
})
it('函数名带下标(⌘ + 点击跳到接口卡片);路径里的 {user_id} 各讲各的函数的参数;方法用 Swagger UI 的颜色', async () => {
const [getUser, updateUser] = properties(await routerCard(ROUTES, 'router')).at(-1)?.items ?? []
expect(getUser.cells[2]).toEqual([{ text: 'get_user', role: 'function', offset: ROUTES.indexOf('get_user(') }])
const paramOf = (fn: string): number => ROUTES.indexOf('user_id: Int4Path', ROUTES.indexOf(`def ${fn}`))
for (const [row, fn] of [[getUser, 'get_user'], [updateUser, 'update_user']] as const)
expect(flatSegments(inlineOf(row.cells[1])).find(piece => piece.text === 'user_id')?.refers).toEqual({ startIndex: paramOf(fn), endIndex: paramOf(fn) + 'user_id'.length })
expect(updateUser.cells[0]).toEqual([{ text: 'PUT', role: 'keyword', color: { dark: '#fca130', light: '#b35f00' } }])
})
it('接口卡片写挂在哪个路由器上(名字带下标,⌘ + 点击跳到路由器卡片),排在说明后面', async () => {
const rows = properties(await route(ROUTES, 'update_user'))
expect(rows.map(row => row.label).slice(0, 4)).toEqual(['名称', '说明', '路由器', '标签'])
expect(rows[2].value).toEqual([{ text: 'router', role: 'variable', offset: ROUTES.indexOf('router = APIRouter') }])
})
it('写了依赖、默认响应才有这两行;FastAPI 应用没有前缀;变量的说明是属性 docstring;没挂接口时「0 个」、不画表格', async () => {
const source = `${HEADER}"""整个应用。"""
empty = APIRouter()
@router.get("/{item_id}")
def show(item_id: int):
"""看一个物品。
带库存。
"""
@app.post("/items")
def create():
pass
`
expect(propertyLines(properties(await routerCard(source, 'router')))).toEqual([
'名称: router',
'前缀: /items',
'标签: 物品',
'依赖: verify_token',
'默认响应: 401',
' 401 | | 没登录',
'接口: 1 个',
' GET | /items/{item_id} | show | 看一个物品。',
])
expect(propertyLines(properties(await routerCard(source, 'app')))).toEqual([
'名称: app',
'说明: 整个应用。',
'接口: 1 个',
' POST | /items | create | ',
])
const empty = properties(await routerCard(source, 'empty'))
expect(propertyLines(empty)).toEqual(['名称: empty', '接口: 0 个'])
expect(empty.at(-1)?.columns).toBeUndefined()
})
})
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()
})
})