diff --git a/vscode/src/codeReader/callTree/endpoint.test.ts b/vscode/src/codeReader/callTree/endpoint.test.ts index 78e4c6d..0d947fd 100644 --- a/vscode/src/codeReader/callTree/endpoint.test.ts +++ b/vscode/src/codeReader/callTree/endpoint.test.ts @@ -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']) + }) }) diff --git a/vscode/src/codeReader/callTree/endpoint.ts b/vscode/src/codeReader/callTree/endpoint.ts index 0859870..c932744 100644 --- a/vscode/src/codeReader/callTree/endpoint.ts +++ b/vscode/src/codeReader/callTree/endpoint.ts @@ -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 { + let shared: Set | 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[] } | { contr /** 接口卡片的前端调用者:契约里对出完整路径后,同一方法的前端请求里最具体地配上这条路径的那些。 */ export async function endpointCalls(find: IndexFind, endpoint: HttpEndpoint): Promise { - 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) } } diff --git a/vscode/src/codeReader/contract.ts b/vscode/src/codeReader/contract.ts index 921a927..0c5d223 100644 --- a/vscode/src/codeReader/contract.ts +++ b/vscode/src/codeReader/contract.ts @@ -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[] } /** diff --git a/vscode/src/codeReader/rules/fastapi.test.ts b/vscode/src/codeReader/rules/fastapi.test.ts index 3b88714..f6be1b8 100644 --- a/vscode/src/codeReader/rules/fastapi.test.ts +++ b/vscode/src/codeReader/rules/fastapi.test.ts @@ -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 () => { diff --git a/vscode/src/codeReader/rules/fastapi.ts b/vscode/src/codeReader/rules/fastapi.ts index 0540eb1..4c9545c 100644 --- a/vscode/src/codeReader/rules/fastapi.ts +++ b/vscode/src/codeReader/rules/fastapi.ts @@ -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 { - 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 { + 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),按赋值目标的位置认出是不是这一块 diff --git a/vscode/webview-ui/src/codeReader/types.ts b/vscode/webview-ui/src/codeReader/types.ts index cdb745e..8c6a9f0 100644 --- a/vscode/webview-ui/src/codeReader/types.ts +++ b/vscode/webview-ui/src/codeReader/types.ts @@ -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 }[] } } /**