✨ feat(vscode): 同名接口靠同一路由对象上的其他接口定挂载前缀

This commit is contained in:
cruldra committed 2026-10-12 12:38:54 +11:00
1 parent 00737ab1a0
commit e66b19b3b4
6 files changed
+129 -27

No files matched your search

@@ -35,6 +35,52 @@ describe('后端路由对到契约里的那一条', () => {
})
})
describe('同一个路由对象上的接口一起定挂载前缀', () => {
/** luffy 的样子:用户端 /agents 和管理端 /admin/agents 里都有 list_agents,挂载前缀都是 /api/v1。 */
const LUFFY = [
op('GET', '/api/v1/agents', 'list_agents_api_v1_agents_get'),
op('GET', '/api/v1/agents/default', 'get_default_agent_api_v1_agents_default_get'),
op('POST', '/api/v1/agents', 'create_agent_api_v1_agents_post'),
op('GET', '/api/v1/admin/agents', 'list_agents_api_v1_admin_agents_get'),
op('GET', '/api/v1/admin/agents/{agent_id}', 'get_agent_api_v1_admin_agents__agent_id__get'),
]
const user = [{ method: 'GET', path: '/agents' }, { method: 'GET', path: '/agents/default' }, { method: 'POST', path: '/agents' }]
const admin = [{ method: 'GET', path: '/admin/agents' }, { method: 'GET', path: '/admin/agents/{agent_id}' }]
it('同名接口分不出时,靠同一个路由对象上别的接口定出前缀(别的方法的也算)', () => {
expect(routeCandidates(LUFFY, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: user })).toEqual(['/api/v1/agents'])
expect(routeCandidates(LUFFY, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: user.filter(route => route.method === 'POST') })).toEqual(['/api/v1/agents'])
})
it('契约里一条都对不上的接口(过期、新加的)不参与,不拖累别的接口', () => {
const stale = [...user, { method: 'GET', path: '/agents/not-generated-yet' }]
expect(routeCandidates(LUFFY, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: stale })).toEqual(['/api/v1/agents'])
})
it('各路由对象按自己的接口算', () => {
expect(routeCandidates(LUFFY, { method: 'GET', path: '/admin/agents', operationIdPrefix: 'list_agents_', sameRouter: admin })).toEqual(['/api/v1/admin/agents'])
})
it('定出的前缀还有几个时退到 operationId 挑;还分不出就照样是几条', () => {
const twice = [
op('GET', '/api/v1/agents', 'list_agents_api_v1_agents_get'),
op('GET', '/api/v1/agents/default', 'get_default_agent_api_v1_agents_default_get'),
op('GET', '/legacy/agents', 'legacy_list_legacy_agents_get'),
op('GET', '/legacy/agents/default', 'legacy_default_legacy_agents_default_get'),
]
const routes = [{ method: 'GET', path: '/agents' }, { method: 'GET', path: '/agents/default' }]
expect(routeCandidates(twice, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: routes })).toEqual(['/api/v1/agents'])
const sameName = twice.map(item => ({ ...item, operationId: item.operationId?.replace('legacy_list_', 'list_agents_') }))
expect(routeCandidates(sameName, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: routes })).toEqual(['/api/v1/agents', '/legacy/agents'])
})
it('各接口的前缀凑不到一起时照只看这一个接口的办法', () => {
const apart = [...LUFFY, op('GET', '/other/x', 'x_other_x_get')]
expect(routeCandidates(apart, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: [{ method: 'GET', path: '/agents' }, { method: 'GET', path: '/x' }] }))
.toEqual(['/api/v1/agents', '/api/v1/admin/agents'])
})
})
describe('前端请求对到契约里最具体的那一条', () => {
const paths = ['/api/v1/agents', '/api/v1/agents/default', '/api/v1/agents/{agent_id}']
@@ -105,4 +151,11 @@ describe('接口卡片找前端调用它的地方', () => {
expect(await calls({ ...files, 'web/src/lib/api/schema.d.ts': admin }, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' }))
.toEqual({ contracts: ['web/src/lib/api/schema.d.ts'], ambiguous: ['/api/v1/admin/agents', '/api/v1/agents'] })
})
it('同名接口靠同一个路由对象上别的接口(别的方法的也查)定出完整网址,找到前端调用者', async () => {
const admin = SCHEMA.replace('export interface paths {', 'export interface paths {\n "/api/v1/admin/agents": {\n get: operations["list_agents_api_v1_admin_agents_get"];\n };')
const sameRouter = [{ method: 'GET', path: '/agents' }, { method: 'POST', path: '/agents' }]
expect(await calls({ ...files, 'web/src/lib/api/schema.d.ts': admin }, { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter }))
.toEqual(['web/src/app/(main)/my-agents/page.tsx MyAgentsPage /api/v1/agents'])
})
})
+39 -11
View File
@@ -3,7 +3,7 @@
* 契约里对不上就交回找到了哪几份契约文件,不猜、不退回去只比网址结尾。
*/
import type { HttpEndpoint, IndexEntry } from '../contract'
import type { EndpointRoute, HttpEndpoint, IndexEntry } from '../contract'
import type { IndexedEntry, WorkspaceIndexOf } from '../workspaceIndex'
import type { OperationEntry } from './openapi'
import type { RequestEntry } from './requests'
@@ -31,24 +31,50 @@ function isParam(segment: string): boolean {
return segment.includes('{')
}
/** 契约路径 full 以后端写的 tail 结尾:字面量段对字面量段要相等,参数段对参数段。 */
function endsWith(full: readonly string[], tail: readonly string[]): boolean {
/** 契约路径 full 以后端写的 tail 结尾(字面量段对字面量段要相等,参数段对参数段)时,前面那截挂载前缀,各段用 / 接起来;不是这样结尾时没有。 */
function mountOf(full: readonly string[], tail: readonly string[]): string | undefined {
const offset = full.length - tail.length
return offset >= 0 && tail.every((segment, i) => isParam(segment) ? isParam(full[offset + i]) : full[offset + i] === segment)
const matched = offset >= 0 && tail.every((segment, i) => isParam(segment) ? isParam(full[offset + i]) : full[offset + i] === segment)
return matched ? full.slice(0, offset).join('/') : undefined
}
/** 接口在契约里结尾对得上的各条(方法相同),连同各自的挂载前缀。 */
function matchesOf(operations: readonly OperationEntry[], route: EndpointRoute): { operation: OperationEntry, mount: string }[] {
const tail = segmentsOf(route.path)
return operations.flatMap((operation) => {
const mount = operation.key === route.method ? mountOf(segmentsOf(operation.path), tail) : undefined
return mount === undefined ? [] : [{ operation, mount }]
})
}
/**
* 同一个路由对象上的接口挂在同一个前缀下:各接口在契约里对得上的挂载前缀求交集。
* 契约里一条都对不上的接口(过期、新加的)不参与;交集是空的、或谁都对不上时是空的。
*/
function sharedMounts(operations: readonly OperationEntry[], routes: readonly EndpointRoute[]): Set<string> {
let shared: Set<string> | undefined
for (const route of routes) {
const mounts = new Set(matchesOf(operations, route).map(({ mount }) => mount))
if (mounts.size > 0)
shared = shared ? new Set([...shared].filter(mount => mounts.has(mount))) : mounts
}
return shared ?? new Set()
}
/**
* 后端路由在契约里可能是哪几条(契约里的完整路径):方法相同、完整路径以本文件的路径结尾;
* 结尾对上几条时只留 operationId 以 operationIdPrefix 开头的。正好一条才算对上,剩几条是分不出,一条不剩是没有。
* 只留挂载前缀落在同一个路由对象上各接口共同的前缀里的(见 sharedMounts,凑不出共同前缀时不筛);
* 还剩几条时只留 operationId 以 operationIdPrefix 开头的。正好一条才算对上,剩几条是分不出,一条不剩是没有。
*/
export function routeCandidates(operations: readonly OperationEntry[], endpoint: HttpEndpoint): string[] {
const tail = segmentsOf(endpoint.path)
const matched = operations.filter(operation => operation.key === endpoint.method && endsWith(segmentsOf(operation.path), tail))
const matched = matchesOf(operations, endpoint)
const mounts = sharedMounts(operations, [endpoint, ...(endpoint.sameRouter ?? [])])
const kept = mounts.size > 0 ? matched.filter(({ mount }) => mounts.has(mount)) : matched
const prefix = endpoint.operationIdPrefix
const candidates = new Set(matched.map(operation => operation.path))
const candidates = new Set(kept.map(({ operation }) => operation.path))
if (candidates.size <= 1 || prefix === undefined)
return [...candidates]
return [...new Set(matched.filter(operation => operation.operationId?.startsWith(prefix)).map(operation => operation.path))]
return [...new Set(kept.filter(({ operation }) => operation.operationId?.startsWith(prefix)).map(({ operation }) => operation.path))]
}
/** 契约路径的各段是不是字面量;从左往右先在字面量上配上的更具体。 */
@@ -83,7 +109,9 @@ export type EndpointCalls = { requests: IndexedEntry<RequestEntry>[] } | { contr
/** 接口卡片的前端调用者:契约里对出完整路径后,同一方法的前端请求里最具体地配上这条路径的那些。 */
export async function endpointCalls(find: IndexFind, endpoint: HttpEndpoint): Promise<EndpointCalls> {
const operations = (await Promise.all(CONTRACT_INDEXES.map(index => find(index, endpoint.method)))).flat().map(({ entry }) => entry)
const methods = new Set([endpoint.method, ...(endpoint.sameRouter ?? []).map(route => route.method)])
const found = await Promise.all(CONTRACT_INDEXES.flatMap(index => [...methods].map(method => find(index, method))))
const operations = found.flat().map(({ entry }) => entry)
const candidates = routeCandidates(operations, endpoint)
if (candidates.length !== 1) {
const all = await Promise.all(CONTRACT_INDEXES.flatMap(index => CONTRACT_METHODS.map(method => find(index, method))))
@@ -91,7 +119,7 @@ export async function endpointCalls(find: IndexFind, endpoint: HttpEndpoint): Pr
return { contracts, ...(candidates.length > 1 && { ambiguous: candidates.sort() }) }
}
const [path] = candidates
const paths = operations.map(operation => operation.path)
const paths = operations.filter(operation => operation.key === endpoint.method).map(operation => operation.path)
const requests = await find(REQUESTS_INDEX, endpoint.method)
return { requests: requests.filter(({ entry }) => requestOperation(paths, entry.path) === path) }
}
+9 -3
View File
@@ -998,14 +998,20 @@ export interface RuleCard {
endpoint?: HttpEndpoint
}
/** 卡片讲的 HTTP 接口。 */
export interface HttpEndpoint {
/** 一个 HTTP 接口在这份文件里的写法。 */
export interface EndpointRoute {
/** 大写的 HTTP 方法 */
method: string
/** 这份文件里写出来的路径,`{参数}` 照写;挂到应用上时别处再叠的前缀不算,宿主按结尾去对完整网址 */
path: string
/** 契约里结尾对上几条时,operationId 以它开头的那条才是(FastAPI 默认的 operationId 以「函数名_」开头) */
}
/** 卡片讲的 HTTP 接口。 */
export interface HttpEndpoint extends EndpointRoute {
/** 契约里结尾对上几条、挂载前缀也定不下来时,operationId 以它开头的那条才是(FastAPI 默认的 operationId 以「函数名_」开头) */
operationIdPrefix?: string
/** 同一个路由对象上的全部接口(含这一个):它们挂在同一个前缀下,契约里结尾对上几条时宿主拿它们一起定挂载前缀 */
sameRouter?: EndpointRoute[]
}
/**
+5 -4
View File
@@ -515,10 +515,11 @@ def dynamic_route():
return None
`
it('卡片带上 HTTP 接口:方法、本文件的前缀接装饰器里的路径、operationId 的开头(函数名_)', async () => {
expect((await route(source, 'list_agents')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' } })
expect((await route(source, 'get_default_agent')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents/default', operationIdPrefix: 'get_default_agent_' } })
expect((await route(source, 'remove')).callTree).toEqual({ endpoint: { method: 'DELETE', path: '/{agent_id}', operationIdPrefix: 'remove_' } })
it('卡片带上 HTTP 接口:方法、本文件的前缀接装饰器里的路径、operationId 的开头(函数名_),和同一个路由对象上的全部接口', async () => {
const agents = [{ method: 'GET', path: '/agents' }, { method: 'GET', path: '/agents/default' }]
expect((await route(source, 'list_agents')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: agents } })
expect((await route(source, 'get_default_agent')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents/default', operationIdPrefix: 'get_default_agent_', sameRouter: agents } })
expect((await route(source, 'remove')).callTree).toEqual({ endpoint: { method: 'DELETE', path: '/{agent_id}', operationIdPrefix: 'remove_', sameRouter: [{ method: 'DELETE', path: '/{agent_id}' }] } })
})
it('前缀或路径不是字符串字面量时算不出路径,卡片不带 HTTP 接口,也就没有调用树', async () => {
+22 -8
View File
@@ -662,7 +662,8 @@ function requestItems(url: Inline[], method: string, placed: readonly Placed[],
}
}
function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
/** 接口卡片;siblings 是同一个路由对象上的全部接口,随卡片讲的 HTTP 接口一起交给宿主定挂载前缀。 */
function routeCard(block: FunctionBlock, route: Route, env: Env, siblings: readonly RouteOn[]): RuleCard {
const { context, words } = env
const { labels } = words
const { facts } = block
@@ -731,16 +732,29 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
...(facts.name && { defines: range(facts.name) }),
attributes: { [METHOD_ATTRIBUTE]: route.method },
absorbs: [],
...(facts.name && endpointOf(route, facts.name.text)),
...(facts.name && endpointOf(route, facts.name.text, siblings)),
}
}
/** 卡片讲的 HTTP 接口:前缀和装饰器里的路径都是字符串字面量(或不写)才算得出路径;FastAPI 默认的 operationId 是函数名接完整路径和方法。 */
function endpointOf(route: Route, fn: string): Pick<RuleCard, 'endpoint'> {
const parts = [route.router.prefix, route.path].map(part => part ? stringContent(part)?.text : '')
if (parts.includes(undefined))
/**
* 卡片讲的 HTTP 接口,连同同一个路由对象上的全部接口(算不出路径的不算):算不出这一个的路径时没有。
* FastAPI 默认的 operationId 是函数名接完整路径和方法。
*/
function endpointOf(route: Route, fn: string, siblings: readonly RouteOn[]): Pick<RuleCard, 'endpoint'> {
const path = localPath(route)
if (path === undefined)
return {}
return { endpoint: { method: route.method, path: parts.join(''), operationIdPrefix: `${fn}_` } }
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('')
}
/**
@@ -805,7 +819,7 @@ export const fastapiRule: LibraryRule = {
for (const decorator of block.facts.decorators) {
const route = routeOf(decorator, routers)
if (route)
return routeCard(block, route, env)
return routeCard(block, route, env, routesOn(root, routers, route.router))
}
}
// 路由对象只认模块顶层的赋值(routersOf),按赋值目标的位置认出是不是这一块
+1 -1
View File
@@ -156,7 +156,7 @@ interface BlockViewBase {
/** 一块能看调用树:接口卡片带上它讲的接口(扩展端查前端调用者用,这边只看有没有 callTree)。 */
export interface CallTreeTarget {
endpoint?: { method: string, path: string, operationIdPrefix?: string }
endpoint?: { method: string, path: string, operationIdPrefix?: string, sameRouter?: { method: string, path: string }[] }
}
/**