✨ feat(vscode): 同名接口靠同一路由对象上的其他接口定挂载前缀
This commit is contained in:
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('前端请求对到契约里最具体的那一条', () => {
|
describe('前端请求对到契约里最具体的那一条', () => {
|
||||||
const paths = ['/api/v1/agents', '/api/v1/agents/default', '/api/v1/agents/{agent_id}']
|
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_' }))
|
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'] })
|
.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'])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -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 { IndexedEntry, WorkspaceIndexOf } from '../workspaceIndex'
|
||||||
import type { OperationEntry } from './openapi'
|
import type { OperationEntry } from './openapi'
|
||||||
import type { RequestEntry } from './requests'
|
import type { RequestEntry } from './requests'
|
||||||
@@ -31,24 +31,50 @@ function isParam(segment: string): boolean {
|
|||||||
return segment.includes('{')
|
return segment.includes('{')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 契约路径 full 以后端写的 tail 结尾:字面量段对字面量段要相等,参数段对参数段。 */
|
/** 契约路径 full 以后端写的 tail 结尾(字面量段对字面量段要相等,参数段对参数段)时,前面那截挂载前缀,各段用 / 接起来;不是这样结尾时没有。 */
|
||||||
function endsWith(full: readonly string[], tail: readonly string[]): boolean {
|
function mountOf(full: readonly string[], tail: readonly string[]): string | undefined {
|
||||||
const offset = full.length - tail.length
|
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[] {
|
export function routeCandidates(operations: readonly OperationEntry[], endpoint: HttpEndpoint): string[] {
|
||||||
const tail = segmentsOf(endpoint.path)
|
const matched = matchesOf(operations, endpoint)
|
||||||
const matched = operations.filter(operation => operation.key === endpoint.method && endsWith(segmentsOf(operation.path), tail))
|
const mounts = sharedMounts(operations, [endpoint, ...(endpoint.sameRouter ?? [])])
|
||||||
|
const kept = mounts.size > 0 ? matched.filter(({ mount }) => mounts.has(mount)) : matched
|
||||||
const prefix = endpoint.operationIdPrefix
|
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)
|
if (candidates.size <= 1 || prefix === undefined)
|
||||||
return [...candidates]
|
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> {
|
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)
|
const candidates = routeCandidates(operations, endpoint)
|
||||||
if (candidates.length !== 1) {
|
if (candidates.length !== 1) {
|
||||||
const all = await Promise.all(CONTRACT_INDEXES.flatMap(index => CONTRACT_METHODS.map(method => find(index, method))))
|
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() }) }
|
return { contracts, ...(candidates.length > 1 && { ambiguous: candidates.sort() }) }
|
||||||
}
|
}
|
||||||
const [path] = candidates
|
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)
|
const requests = await find(REQUESTS_INDEX, endpoint.method)
|
||||||
return { requests: requests.filter(({ entry }) => requestOperation(paths, entry.path) === path) }
|
return { requests: requests.filter(({ entry }) => requestOperation(paths, entry.path) === path) }
|
||||||
}
|
}
|
||||||
@@ -998,14 +998,20 @@ export interface RuleCard {
|
|||||||
endpoint?: HttpEndpoint
|
endpoint?: HttpEndpoint
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 卡片讲的 HTTP 接口。 */
|
/** 一个 HTTP 接口在这份文件里的写法。 */
|
||||||
export interface HttpEndpoint {
|
export interface EndpointRoute {
|
||||||
/** 大写的 HTTP 方法 */
|
/** 大写的 HTTP 方法 */
|
||||||
method: string
|
method: string
|
||||||
/** 这份文件里写出来的路径,`{参数}` 照写;挂到应用上时别处再叠的前缀不算,宿主按结尾去对完整网址 */
|
/** 这份文件里写出来的路径,`{参数}` 照写;挂到应用上时别处再叠的前缀不算,宿主按结尾去对完整网址 */
|
||||||
path: string
|
path: string
|
||||||
/** 契约里结尾对上几条时,operationId 以它开头的那条才是(FastAPI 默认的 operationId 以「函数名_」开头) */
|
}
|
||||||
|
|
||||||
|
/** 卡片讲的 HTTP 接口。 */
|
||||||
|
export interface HttpEndpoint extends EndpointRoute {
|
||||||
|
/** 契约里结尾对上几条、挂载前缀也定不下来时,operationId 以它开头的那条才是(FastAPI 默认的 operationId 以「函数名_」开头) */
|
||||||
operationIdPrefix?: string
|
operationIdPrefix?: string
|
||||||
|
/** 同一个路由对象上的全部接口(含这一个):它们挂在同一个前缀下,契约里结尾对上几条时宿主拿它们一起定挂载前缀 */
|
||||||
|
sameRouter?: EndpointRoute[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -515,10 +515,11 @@ def dynamic_route():
|
|||||||
return None
|
return None
|
||||||
`
|
`
|
||||||
|
|
||||||
it('卡片带上 HTTP 接口:方法、本文件的前缀接装饰器里的路径、operationId 的开头(函数名_)', async () => {
|
it('卡片带上 HTTP 接口:方法、本文件的前缀接装饰器里的路径、operationId 的开头(函数名_),和同一个路由对象上的全部接口', async () => {
|
||||||
expect((await route(source, 'list_agents')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_' } })
|
const agents = [{ method: 'GET', path: '/agents' }, { method: 'GET', path: '/agents/default' }]
|
||||||
expect((await route(source, 'get_default_agent')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents/default', operationIdPrefix: 'get_default_agent_' } })
|
expect((await route(source, 'list_agents')).callTree).toEqual({ endpoint: { method: 'GET', path: '/agents', operationIdPrefix: 'list_agents_', sameRouter: agents } })
|
||||||
expect((await route(source, 'remove')).callTree).toEqual({ endpoint: { method: 'DELETE', path: '/{agent_id}', operationIdPrefix: 'remove_' } })
|
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 () => {
|
it('前缀或路径不是字符串字面量时算不出路径,卡片不带 HTTP 接口,也就没有调用树', async () => {
|
||||||
|
|||||||
@@ -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 { context, words } = env
|
||||||
const { labels } = words
|
const { labels } = words
|
||||||
const { facts } = block
|
const { facts } = block
|
||||||
@@ -731,16 +732,29 @@ function routeCard(block: FunctionBlock, route: Route, env: Env): RuleCard {
|
|||||||
...(facts.name && { defines: range(facts.name) }),
|
...(facts.name && { defines: range(facts.name) }),
|
||||||
attributes: { [METHOD_ATTRIBUTE]: route.method },
|
attributes: { [METHOD_ATTRIBUTE]: route.method },
|
||||||
absorbs: [],
|
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'> {
|
* 卡片讲的 HTTP 接口,连同同一个路由对象上的全部接口(算不出路径的不算):算不出这一个的路径时没有。
|
||||||
const parts = [route.router.prefix, route.path].map(part => part ? stringContent(part)?.text : '')
|
* FastAPI 默认的 operationId 是函数名接完整路径和方法。
|
||||||
if (parts.includes(undefined))
|
*/
|
||||||
|
function endpointOf(route: Route, fn: string, siblings: readonly RouteOn[]): Pick<RuleCard, 'endpoint'> {
|
||||||
|
const path = localPath(route)
|
||||||
|
if (path === undefined)
|
||||||
return {}
|
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) {
|
for (const decorator of block.facts.decorators) {
|
||||||
const route = routeOf(decorator, routers)
|
const route = routeOf(decorator, routers)
|
||||||
if (route)
|
if (route)
|
||||||
return routeCard(block, route, env)
|
return routeCard(block, route, env, routesOn(root, routers, route.router))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 路由对象只认模块顶层的赋值(routersOf),按赋值目标的位置认出是不是这一块
|
// 路由对象只认模块顶层的赋值(routersOf),按赋值目标的位置认出是不是这一块
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ interface BlockViewBase {
|
|||||||
|
|
||||||
/** 一块能看调用树:接口卡片带上它讲的接口(扩展端查前端调用者用,这边只看有没有 callTree)。 */
|
/** 一块能看调用树:接口卡片带上它讲的接口(扩展端查前端调用者用,这边只看有没有 callTree)。 */
|
||||||
export interface CallTreeTarget {
|
export interface CallTreeTarget {
|
||||||
endpoint?: { method: string, path: string, operationIdPrefix?: string }
|
endpoint?: { method: string, path: string, operationIdPrefix?: string, sameRouter?: { method: string, path: string }[] }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in new issue
Block a user