fix(group): detect httpx async consumers

This commit is contained in:
juyua9 2026-05-07 19:05:22 +08:00
parent 8e76729750
commit 68bd835198
2 changed files with 168 additions and 0 deletions

View file

@ -1,3 +1,4 @@
import type Parser from 'tree-sitter';
import Python from 'tree-sitter-python';
import {
compilePatterns,
@ -12,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js';
* - FastAPI `@app.get("/path")` provider decorators
* - `requests.get/post/...("url")` consumer calls
* - Generic `requests.request("METHOD", "url")` consumer calls
* - `httpx.AsyncClient` instances calling `.get/.post/...("url")`
*/
const FASTAPI_VERBS: Record<string, string> = {
@ -77,11 +79,103 @@ const REQUESTS_GENERIC_PATTERNS = compilePatterns({
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: httpx.AsyncClient assignments ────────────────────────
const HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-assign',
language: Python,
patterns: [
{
meta: {},
query: `
(assignment
left: (_) @client
right: (call
function: (attribute
object: (identifier) @module (#eq? @module "httpx")
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient"))))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: async with httpx.AsyncClient() as client ──────────────
const HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-with-alias',
language: Python,
patterns: [
{
meta: {},
query: `
(as_pattern
(call
function: (attribute
object: (identifier) @module (#eq? @module "httpx")
attribute: (identifier) @client_class (#eq? @client_class "AsyncClient")))
(as_pattern_target (identifier) @client))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: httpx AsyncClient .get/.post/...("url") ──────────────
const HTTPX_ASYNC_CLIENT_VERB_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-verb',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (_) @client
attribute: (identifier) @method (#match? @method "^(get|post|put|delete|patch)$"))
arguments: (argument_list . (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
// ─── Consumer: httpx AsyncClient .request("METHOD", "url") ─────────
const HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS = compilePatterns({
name: 'python-httpx-async-client-generic',
language: Python,
patterns: [
{
meta: {},
query: `
(call
function: (attribute
object: (_) @client
attribute: (identifier) @method (#eq? @method "request"))
arguments: (argument_list . (string) @http_method (string) @path))
`,
},
],
} satisfies LanguagePatterns<Record<string, never>>);
function collectHttpxAsyncClients(tree: Parser.Tree): Set<string> {
const clients = new Set<string>();
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_ASSIGN_PATTERNS, tree)) {
const clientNode = match.captures.client;
if (clientNode) clients.add(clientNode.text);
}
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_WITH_ALIAS_PATTERNS, tree)) {
const clientNode = match.captures.client;
if (clientNode) clients.add(clientNode.text);
}
return clients;
}
export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
name: 'python-http',
language: Python,
scan(tree) {
const out: HttpDetection[] = [];
const httpxAsyncClients = collectHttpxAsyncClients(tree);
// Providers: FastAPI
for (const match of runCompiledPatterns(FASTAPI_PATTERNS, tree)) {
@ -137,6 +231,45 @@ export const PYTHON_HTTP_PLUGIN: HttpLanguagePlugin = {
});
}
// Consumers: httpx.AsyncClient.<verb>("url")
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_VERB_PATTERNS, tree)) {
const clientNode = match.captures.client;
const methodNode = match.captures.method;
const pathNode = match.captures.path;
if (!clientNode || !methodNode || !pathNode) continue;
if (!httpxAsyncClients.has(clientNode.text)) continue;
const path = unquoteLiteral(pathNode.text);
if (path === null) continue;
out.push({
role: 'consumer',
framework: 'python-httpx',
method: methodNode.text.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
// Consumers: httpx.AsyncClient.request("METHOD", "url")
for (const match of runCompiledPatterns(HTTPX_ASYNC_CLIENT_GENERIC_PATTERNS, tree)) {
const clientNode = match.captures.client;
const methodNode = match.captures.http_method;
const pathNode = match.captures.path;
if (!clientNode || !methodNode || !pathNode) continue;
if (!httpxAsyncClients.has(clientNode.text)) continue;
const methodRaw = unquoteLiteral(methodNode.text);
const path = unquoteLiteral(pathNode.text);
if (methodRaw === null || path === null) continue;
out.push({
role: 'consumer',
framework: 'python-httpx',
method: methodRaw.toUpperCase(),
path,
name: null,
confidence: 0.7,
});
}
return out;
},
};

View file

@ -428,6 +428,41 @@ def create_order():
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
).toBeDefined();
});
it('extracts Python httpx.AsyncClient calls assigned to attributes or aliases', async () => {
const dir = path.join(tmpDir, 'python-httpx-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'client.py'),
`
import httpx
class TopicClient:
def __init__(self):
self._client = httpx.AsyncClient(base_url="https://svc.local")
async def list_topics(self):
return await self._client.get("/topic")
async def publish(self):
return await self._client.request("POST", "/questions/import")
async def check_duplicate():
async with httpx.AsyncClient() as client:
return await client.post("https://svc.local/questions/duplicate-check")
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(consumers.find((c) => c.contractId === 'http::GET::/topic')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/questions/import')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::POST::/questions/duplicate-check'),
).toBeDefined();
expect(consumers.every((c) => c.meta.framework === 'python-httpx')).toBe(true);
});
it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => {
const dir = path.join(tmpDir, 'java-consumer');