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

859 lines
42 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 { FunctionBlock, ImportFacts, IndexEntry, Inline, KindIconName, LibraryRule, Node, ParameterFacts, PreviewText, PropertyItem, PropertyRow, RuleCard, RuleContext, Segment, ThemeColor, VariableBlock, WorkspaceIndex } from '../contract'
import type { ParamDocs } from './docRows'
import type { Call, LibraryNames } from './pythonTree'
import { docBodyRows, paramDocs, returnNote, throwsRows, unmatchedParamRows } from './docRows'
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: KindIconName = 'globe'
const ROUTER_KIND = 'fastapi.router'
const ROUTER_COLOR: ThemeColor = { dark: '#7aa2f7', light: '#34548a' }
const ROUTER_ICON: KindIconName = 'network'
/** 认路由对象、参数标记、HTTPException 时看从这几个库导入的名字(HTTPException、status 也可以从 starlette 来)。 */
const NAME_LIBRARIES = ['fastapi', 'starlette']
/** 接口的筛选属性:HTTP 方法,值是大写的 GET、POST……(名字由 attributeNames 给,值照写)。 */
const METHOD_ATTRIBUTE = 'httpMethod'
/** 路由对象上挂接口的方法:`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
routerKind: string
labels: Record<'name' | 'doc' | 'router' | 'tags' | 'async' | 'decorators' | 'dependencies' | 'request' | 'responses' | 'prefix' | 'defaultResponses' | 'routes', string>
yes: string
no: string
count: (n: number) => string
requestHeads: string[]
/** 请求表里有参数写了说明(`@param`)时多出的那一列 */
requestDoc: string
responseHeads: string[]
/** 路由器卡片的接口表:方法 | 路径 | 函数 | 说明 */
routeHeads: 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
/** 筛选属性 httpMethod 叫什么(值 GET、POST……照写) */
methodAttribute: string
}
const ZH: Words = {
kind: '路由',
routerKind: '路由器',
labels: {
name: '名称',
doc: '说明',
router: '路由器',
tags: '标签',
async: '异步',
decorators: '装饰器',
dependencies: '依赖',
request: '请求',
responses: '响应',
prefix: '前缀',
defaultResponses: '默认响应',
routes: '接口',
},
yes: '是',
no: '否',
count: n => `${n} 个`,
requestHeads: ['项', '值'],
requestDoc: '说明',
responseHeads: ['状态码', '内容', '说明'],
routeHeads: ['方法', '路径', '函数', '说明'],
items: { url: 'URL', method: '方法', path: '路径参数', query: '查询参数', header: '请求头', cookie: 'Cookie', body: '请求体' },
success: '成功',
validation: '参数校验失败',
automatic: 'FastAPI 自动返回',
listSeparator: '、',
messageSeparator: ';',
injectedInto: param => `(${param})`,
methodAttribute: '请求方法',
}
/** 显示语言 → 用词;翻译插件的显示语言这里没有时用中文。 */
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, names: LibraryNames): string | undefined {
return node?.type === 'call' ? libraryName(node.childForFieldName('function'), 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 {
/** 赋值目标:路由对象的名字 */
target: Node
prefix?: Node
tags: Node[]
dependencies: Node[]
responses?: Node
}
function routersOf(root: Node, names: LibraryNames): 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, names)
const call = callOf(value, new Map())
if (target?.type !== 'identifier' || !call || (kind !== 'APIRouter' && kind !== 'FastAPI'))
continue
routers.set(target.text, {
target,
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') }
}
/** 路由器卡片接口表里的一个接口:挂在这个路由对象上的函数(模块顶层的)。 */
interface RouteOn {
route: Route
definition: Node
name: Node
}
/** 这份文件模块顶层挂在各路由对象上的接口,照源码顺序;一个函数几个路由装饰器时同接口卡片,取第一个。 */
function routesOn(root: Node, routers: ReadonlyMap<string, Router>): Map<Router, RouteOn[]> {
const routes = new Map<Router, RouteOn[]>()
for (const statement of syntaxChildren(root)) {
const definition = statement.type === 'decorated_definition' ? statement.childForFieldName('definition') : null
const name = definition?.type === 'function_definition' ? definition.childForFieldName('name') : null
if (!definition || !name)
continue
const route = syntaxChildren(statement).filter(child => child.type === 'decorator').map(decorator => routeOf(decorator, routers)).find(found => found !== undefined)
if (route)
routes.set(route.router, [...routes.get(route.router) ?? [], { route, definition, name }])
}
return routes
}
/** 认接口要整份扫一遍才知道的:fastapi / starlette 的名字、模块顶层的路由对象、各路由对象上挂的接口。 */
interface FileScan {
names: LibraryNames
routers: Map<string, Router>
routes: Map<Router, RouteOn[]>
}
/** 宿主每读一份文档给各块同一个 context:整份文件按它扫一遍,这份文档的各块共用。 */
const scans = new WeakMap<RuleContext, FileScan>()
function scanOf(root: Node, context: RuleContext): FileScan {
let scan = scans.get(context)
if (!scan) {
const names = namesOf(context.imports)
const routers = routersOf(root, names)
scan = { names, routers, routes: routesOn(root, routers) }
scans.set(context, scan)
}
return scan
}
/** 函数的参数名 → 定义处那个名字(`*args` 这种不算),路径里 `{参数}` 讲的是它。 */
function parametersOf(definition: Node): Map<string, Node> {
const names = syntaxChildren(definition.childForFieldName('parameters')).map(param =>
param.type === 'identifier' ? param : param.type === 'typed_parameter' ? syntaxChildren(param)[0] : param.childForFieldName('name'))
return new Map(names.flatMap(param => param?.type === 'identifier' ? [[param.text, param] as const] : []))
}
/** 函数 docstring 正文的第一行(翻译插件读出的说明,块标签已经拿掉),没有 docstring 时没有。 */
function docFirstLine(definition: Node, env: Env): string | undefined {
const [first] = syntaxChildren(definition.childForFieldName('body'))
const [doc] = first?.type === 'expression_statement' ? syntaxChildren(first) : []
const markdown = doc?.type === 'string' ? env.context.doc(doc)?.body?.markdown : undefined
return markdown?.split('\n').map(line => line.trim()).find(line => line !== '')
}
/**
* 路径里的一段:`{参数}` 按变量着色,其余按字符串,都带源码下标;花括号里的名字讲的是函数的同名参数(params),
* 标上 refers,高亮参数的各处出现时它也跟着亮、⌘ + 点击跳到参数。写的不是字符串时交给翻译插件翻。
*/
function pathSegments(node: Node | undefined, params: ReadonlyMap<string, Node>, 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)) {
if (!part.startsWith('{')) {
segments.push({ text: part, role: 'string', offset: at })
}
else {
// `{file_path:path}`:冒号前面是参数名,后面是转换器
const colon = part.indexOf(':')
const key = part.slice(1, colon < 0 ? -1 : colon)
const rest = part.slice(1 + key.length, -1)
const param = params.get(key.trim())
const pieces: Segment[] = [
{ text: '{', role: 'variable', offset: at },
{ text: key, role: 'variable', offset: at + 1, ...(param && { refers: range(param) }) },
{ text: `${rest}}`, role: 'variable', offset: at + 1 + key.length },
]
segments.push(...pieces.filter(piece => piece.text !== ''))
}
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.names))
const type = annotated?.type ?? (parameter.type && unwrapType(parameter.type))
const markerCall = MARKER_PLACES[calledName(parameter.default, env.names) ?? ''] ? parameter.default : annotated?.marker
const marker = markerCall && calledName(markerCall, env.names)
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.names) ?? ''] === '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 responsesRow(label: string, statuses: Statuses, env: Env): PropertyRow {
const { words } = env
const rows = statuses.rows()
return {
label,
value: [hint(rows.map(row => textOf(row.code)).join(' / '))],
columns: words.responseHeads,
items: rows.map(row => ({
cells: [row.code, joined(row.contents, hint(words.listSeparator)), joined(row.notes, { text: words.messageSeparator, role: 'comment' })],
})),
}
}
/** HTTP 方法:大写,按 Swagger UI 的配色(没有配色的按关键字着色)。 */
function methodText(method: string): Inline[] {
return [{ text: method, role: 'keyword', ...(METHOD_COLORS[method] && { color: METHOD_COLORS[method] }) }]
}
/** 请求表的一行:项 | 值,表里有说明一列时再加说明(note,没有说明的是空的)。 */
function requestItem(label: string, value: Inline[], note?: PreviewText): PropertyItem {
return { cells: [[hint(label)], value, ...(note ? [note] : [])] }
}
/**
* 请求表的各项:URL、方法,然后路径参数、查询参数、请求头、Cookie、请求体,有才列。说明里有请求参数对上了 `@param`(docs)时
* described:表多一列说明,这几种参数一个参数一行、各带它的说明;没有时同一种参数逗号隔开写在一行。
*/
function requestItems(url: Inline[], method: string, placed: readonly Placed[], docs: ParamDocs, env: Env): { items: PropertyItem[], described: boolean } {
const { words, context } = env
const described = placed.some(item => item.place !== 'dependency' && docs.of(item.param.text))
const item = (label: string, value: Inline[], note?: PreviewText): PropertyItem => requestItem(label, value, described ? note ?? [] : undefined)
const at = (place: Place): Placed[] => placed.filter(one => one.place === place)
const listed = (place: Exclude<Place, 'dependency'>): PropertyItem[] => {
const found = at(place)
if (described)
return found.map(one => item(words.items[place], paramsText([one], env), docs.of(one.param.text)))
return found.length > 0 ? [item(words.items[place], paramsText(found, env))] : []
}
const body = at('body')
const [only] = body
return {
items: [
item(words.items.url, url),
item(words.items.method, methodText(method)),
...(['path', 'query', 'header', 'cookie'] as const).flatMap(listed),
...(body.length === 1 && only?.type ? [item(words.items.body, context.translate(only.type), docs.of(only.param.text))] : listed('body')),
],
described,
}
}
/** 接口卡片;siblings 是同一个路由对象上的全部接口,随卡片讲的 HTTP 接口一起交给宿主定挂载前缀。 */
function routeCard(block: FunctionBlock, route: Route, env: Env, siblings: readonly RouteOn[]): 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 params = new Map(facts.parameters.flatMap(({ name: param }) => param.type === 'identifier' ? [[param.text, param] as const] : []))
const url = [...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)]
const doc = facts.doc && context.doc(facts.doc)
const docs = paramDocs(doc, facts.parameters.map(parameter => parameter.name.text))
const request = requestItems(url, route.method, placed, docs, 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 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')] }] : []),
...docBodyRows(doc, labels.doc),
...unmatchedParamRows(docs.unmatched),
{ label: labels.router, value: [name(router.target, 'variable')] },
...(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: request.described ? [...words.requestHeads, words.requestDoc] : words.requestHeads,
items: request.items,
},
{ ...responsesRow(labels.responses, statuses, env), ...returnNote(doc) },
...throwsRows(doc),
]
return {
kind: KIND,
badge: { label: words.kind, color: COLOR, icon: ICON },
rows,
...(facts.name && { defines: range(facts.name) }),
attributes: { [METHOD_ATTRIBUTE]: route.method },
absorbs: [],
...(facts.name && endpointOf(route, facts.name.text, siblings)),
}
}
/**
* 卡片讲的 HTTP 接口,连同同一个路由对象上的全部接口(算不出路径的不算):算不出这一个的路径时没有。
* FastAPI 默认的 operationId 是函数名接完整路径和方法。
*/
function endpointOf(route: Route, fn: string, siblings: readonly RouteOn[]): Pick<RuleCard, 'endpoint'> {
const path = localPath(route)
if (path === undefined)
return {}
const sameRouter = siblings.flatMap(({ route: other }) => {
const otherPath = localPath(other)
return otherPath === undefined ? [] : [{ method: other.method, path: otherPath }]
})
return { endpoint: { method: route.method, path, operationIdPrefix: `${fn}_`, sameRouter } }
}
/** 接口在这份文件里写出来的路径(路由对象的前缀接装饰器里的路径):两段都是字符串字面量(或不写)才算得出。 */
function localPath(route: Route): string | undefined {
const parts = [route.router.prefix, route.path].map(part => part ? stringContent(part)?.text : '')
return parts.includes(undefined) ? undefined : parts.join('')
}
/**
* 路由器卡片:名称 / 说明 / 前缀 / 标签 / 依赖 / 默认响应(有才显示),接口表列这份文件里挂在它上面的全部接口:
* 方法 | 路径(前缀 + 装饰器里的路径,`{参数}` 讲那个函数的参数)| 函数(⌘ + 点击跳到那张接口卡片)| 说明(docstring 第一行)。
*/
function routerCard(block: VariableBlock, router: Router, routes: readonly RouteOn[], env: Env): RuleCard {
const { context, words } = env
const { labels } = words
const separator = hint(words.listSeparator)
const doc = block.facts.doc && context.doc(block.facts.doc)?.body
const tags = router.tags.map(tag => tagText(tag, env))
const dependencies = listedDependencies(router.dependencies, env)
const statuses = new Statuses()
addResponses(router.responses, statuses, env)
const rows: PropertyRow[] = [
{ label: labels.name, value: [name(router.target, 'variable')] },
...(doc ? [{ label: labels.doc, value: doc }] : []),
...(router.prefix ? [{ label: labels.prefix, value: pathSegments(router.prefix, new Map(), env) }] : []),
...(tags.length > 0 ? [{ label: labels.tags, value: joined(tags, separator) }] : []),
...(dependencies.length > 0 ? [{ label: labels.dependencies, value: joined(dependencies, separator) }] : []),
...(statuses.rows().length > 0 ? [responsesRow(labels.defaultResponses, statuses, env)] : []),
{
label: labels.routes,
value: [hint(words.count(routes.length))],
...(routes.length > 0 && {
columns: words.routeHeads,
items: routes.map(({ route, definition, name: fn }) => {
const params = parametersOf(definition)
const doc = docFirstLine(definition, env)
return {
cells: [
methodText(route.method),
[...pathSegments(router.prefix, params, env), ...pathSegments(route.path, params, env)],
[name(fn, 'function')],
doc ? [{ text: doc, role: 'comment' }] : [],
],
}
}),
}),
},
]
return {
kind: ROUTER_KIND,
badge: { label: words.routerKind, color: ROUTER_COLOR, icon: ROUTER_ICON },
rows,
defines: range(router.target),
absorbs: [],
}
}
export const fastapiRule: LibraryRule = {
id: RULE_ID,
label: 'FastAPI 接口',
languageIds: ['python'],
libraries: ['fastapi'],
claim: (block, context) => {
const { names, routers, routes } = scanOf(block.node.tree.rootNode, context)
const env: Env = { context, words: WORDS[context.displayLanguage ?? ''] ?? ZH, names }
if (block.kind === 'function' && block.facts.decorators.length > 0) {
for (const decorator of block.facts.decorators) {
const route = routeOf(decorator, routers)
if (route)
return routeCard(block, route, env, routes.get(route.router) ?? [])
}
}
// 路由对象只认模块顶层的赋值(routersOf),按赋值目标的位置认出是不是这一块
if ((block.kind === 'const' || block.kind === 'variable') && block.facts.declarators.length === 1) {
const target = block.facts.declarators[0].name
const router = routers.get(target.text)
if (router?.target.startIndex === target.startIndex)
return routerCard(block, router, routes.get(router) ?? [], env)
}
return undefined
},
attributeNames: displayLanguage => ({ attributes: { [METHOD_ATTRIBUTE]: (WORDS[displayLanguage ?? ''] ?? ZH).methodAttribute }, values: {} }),
}