feat(group): expand http extractor coverage across Go, NestJS, Python and Java

This commit is contained in:
ivkond 2026-04-09 03:54:03 +03:00
parent fa80ce6a25
commit f614578926
2 changed files with 313 additions and 3 deletions

View file

@ -226,7 +226,7 @@ export class HttpRouteExtractor implements ContractExtractor {
}
private async extractProvidersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py}', {
const files = await glob('**/*.{ts,tsx,js,jsx,java,vue,svelte,php,py,go}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'],
nodir: true,
@ -236,7 +236,9 @@ export class HttpRouteExtractor implements ContractExtractor {
const content = readSafe(repoPath, rel);
if (!content) continue;
out.push(...this.scanSpringProviders(content, rel));
out.push(...this.scanNestProviders(content, rel));
out.push(...this.scanExpressProviders(content, rel));
out.push(...this.scanGoProviders(content, rel));
out.push(...this.scanLaravelProviders(content, rel));
out.push(...this.scanFastApiProviders(content, rel));
}
@ -312,6 +314,48 @@ export class HttpRouteExtractor implements ContractExtractor {
return out;
}
private scanNestProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const controllerMatch = content.match(/@Controller\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/);
const controllerPrefix = controllerMatch ? controllerMatch[1].replace(/\/+$/, '') : '';
const re = /@(Get|Post|Put|Delete|Patch)\s*\(\s*['"`]?([^'"`)]*)['"`]?\s*\)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const routePath = String(m[2] || '');
const fullPath = controllerPrefix
? `${controllerPrefix}/${routePath.replace(/^\/+/, '')}`
: routePath;
const pathNorm = normalizeHttpPath(fullPath.startsWith('/') ? fullPath : `/${fullPath}`);
const sub = content.slice(m.index);
const nameMatch = sub.match(
/(?:public|protected|private)?\s*(?:async\s+)?(\w+)\s*\([^)]*\)\s*\{/,
);
const name = nameMatch ? nameMatch[1] : m[0];
out.push(this.makeProvider(filePath, method, pathNorm, name, 0.8));
}
return out;
}
private scanGoProviders(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const frameworkRe =
/(?:^|\W)\w+\.(GET|POST|PUT|DELETE|PATCH)\s*\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)/gim;
let m: RegExpExecArray | null;
while ((m = frameworkRe.exec(content)) !== null) {
out.push(this.makeProvider(filePath, m[1].toUpperCase(), normalizeHttpPath(m[2]), m[3], 0.8));
}
const handleFuncRe =
/(?:http|\w+)\.HandleFunc\s*\(\s*['"]([^'"]+)['"]\s*,\s*(\w+)\s*\)(?:\s*\.\s*Methods\s*\(\s*['"](\w+)['"]\s*\))?/gim;
while ((m = handleFuncRe.exec(content)) !== null) {
const method = (m[3] || 'GET').toUpperCase();
out.push(this.makeProvider(filePath, method, normalizeHttpPath(m[1]), m[2], 0.8));
}
return out;
}
private makeProvider(
filePath: string,
method: string,
@ -407,7 +451,7 @@ export class HttpRouteExtractor implements ContractExtractor {
}
private async extractConsumersSourceScan(repoPath: string): Promise<ExtractedContract[]> {
const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte}', {
const files = await glob('**/*.{ts,tsx,js,jsx,vue,svelte,py,java,go}', {
cwd: repoPath,
ignore: ['**/node_modules/**', '**/.git/**'],
nodir: true,
@ -418,6 +462,9 @@ export class HttpRouteExtractor implements ContractExtractor {
if (!content) continue;
out.push(...this.scanFetchConsumers(content, rel));
out.push(...this.scanAxiosConsumers(content, rel));
out.push(...this.scanPythonRequestsConsumers(content, rel));
out.push(...this.scanJavaConsumers(content, rel));
out.push(...this.scanGoConsumers(content, rel));
}
return this.dedupeContracts(out);
}
@ -439,18 +486,111 @@ export class HttpRouteExtractor implements ContractExtractor {
return url.replace(/\$\{[^}]+\}/g, '{param}');
}
private normalizeConsumerPath(url: string): string {
const templated = this.templateToPattern(url.trim());
let pathOnly = templated;
if (/^https?:\/\//i.test(templated)) {
try {
pathOnly = new URL(templated).pathname;
} catch {
pathOnly = templated.replace(/^https?:\/\/[^/]+/i, '');
}
}
const normalized = normalizeHttpPath(pathOnly || '/');
const segments = normalized.split('/').filter(Boolean).map((segment) => {
if (/^\d+$/.test(segment)) return '{param}';
return segment;
});
return `/${segments.join('/')}`.replace(/\/+$/, '') || '/';
}
private scanAxiosConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const re = /axios\.(get|post|put|delete|patch)\s*\(\s*[`'"]([^`'"]+)[`'"]/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
const method = m[1].toUpperCase();
const pathNorm = normalizeHttpPath(this.templateToPattern(m[2]));
const pathNorm = this.normalizeConsumerPath(m[2]);
out.push(this.makeConsumer(filePath, method, pathNorm, 0.7));
}
return out;
}
private scanPythonRequestsConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const methodRe = /requests\.(get|post|put|delete|patch)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = methodRe.exec(content)) !== null) {
out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7));
}
const genericRe =
/requests\.request\s*\(\s*['"](\w+)['"]\s*,\s*['"]([^'"]+)['"]/gi;
while ((m = genericRe.exec(content)) !== null) {
out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7));
}
return out;
}
private scanJavaConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const restTemplateMethods: Array<[RegExp, string]> = [
[/restTemplate\.getFor(?:Object|Entity)\s*\(\s*['"]([^'"]+)['"]/gi, 'GET'],
[/restTemplate\.postFor(?:Object|Entity)\s*\(\s*['"]([^'"]+)['"]/gi, 'POST'],
[/restTemplate\.put\s*\(\s*['"]([^'"]+)['"]/gi, 'PUT'],
[/restTemplate\.delete\s*\(\s*['"]([^'"]+)['"]/gi, 'DELETE'],
[/restTemplate\.patchForObject\s*\(\s*['"]([^'"]+)['"]/gi, 'PATCH'],
];
for (const [re, method] of restTemplateMethods) {
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
out.push(this.makeConsumer(filePath, method, this.normalizeConsumerPath(m[1]), 0.7));
}
}
const webClientMethodRe =
/webClient\.method\s*\(\s*HttpMethod\.(GET|POST|PUT|DELETE|PATCH)\s*,\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = webClientMethodRe.exec(content)) !== null) {
out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7));
}
const okHttpRe =
/new\s+Request\.Builder\s*\(\)\s*\.url\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.\s*method\s*\(\s*['"](\w+)['"])?/gim;
while ((m = okHttpRe.exec(content)) !== null) {
out.push(
this.makeConsumer(filePath, (m[2] || 'GET').toUpperCase(), this.normalizeConsumerPath(m[1]), 0.7),
);
}
return out;
}
private scanGoConsumers(content: string, filePath: string): ExtractedContract[] {
const out: ExtractedContract[] = [];
const httpMethodRe = /\bhttp\.(Get|Post|Head)\s*\(\s*['"]([^'"]+)['"]/gi;
let m: RegExpExecArray | null;
while ((m = httpMethodRe.exec(content)) !== null) {
const method = m[1].toUpperCase() === 'HEAD' ? 'GET' : m[1].toUpperCase();
out.push(this.makeConsumer(filePath, method, this.normalizeConsumerPath(m[2]), 0.7));
}
const newRequestRe =
/\bhttp\.NewRequest\s*\(\s*['"](\w+)['"]\s*,\s*['"]([^'"]+)['"]/gi;
while ((m = newRequestRe.exec(content)) !== null) {
out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7));
}
const restyRe = /\b\w+\.R\(\)\.(Get|Post|Put|Delete|Patch)\s*\(\s*['"]([^'"]+)['"]/gi;
while ((m = restyRe.exec(content)) !== null) {
out.push(this.makeConsumer(filePath, m[1].toUpperCase(), this.normalizeConsumerPath(m[2]), 0.7));
}
return out;
}
private makeConsumer(
filePath: string,
method: string,

View file

@ -157,6 +157,89 @@ export default router;
providers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
).toBeDefined();
});
it('extracts Go Gin and Echo route registrations', async () => {
const dir = path.join(tmpDir, 'go-frameworks');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd', 'server.go'),
`
package main
func createOrder(c *gin.Context) {}
func listOrders(c echo.Context) error { return nil }
func main() {
r := gin.Default()
r.POST("/api/orders/:id", createOrder)
e := echo.New()
e.GET("/api/orders", listOrders)
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const ginRoute = providers.find((c) => c.contractId === 'http::POST::/api/orders/{param}');
expect(ginRoute).toBeDefined();
expect(ginRoute?.symbolName).toBe('createOrder');
const echoRoute = providers.find((c) => c.contractId === 'http::GET::/api/orders');
expect(echoRoute).toBeDefined();
expect(echoRoute?.symbolName).toBe('listOrders');
});
it('extracts stdlib HandleFunc providers', async () => {
const dir = path.join(tmpDir, 'go-stdlib-provider');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd', 'server.go'),
`
package main
func healthHandler(w http.ResponseWriter, r *http.Request) {}
func main() {
http.HandleFunc("/api/health", healthHandler)
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const healthRoute = providers.find((c) => c.contractId === 'http::GET::/api/health');
expect(healthRoute).toBeDefined();
expect(healthRoute?.symbolName).toBe('healthHandler');
});
it('extracts NestJS controller decorators', async () => {
const dir = path.join(tmpDir, 'nestjs');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'orders.controller.ts'),
`
import { Controller, Patch } from '@nestjs/common';
@Controller('orders')
export class OrdersController {
@Patch(':id')
updateOrder() {
return {};
}
}
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const providers = contracts.filter((c) => c.role === 'provider');
const patchRoute = providers.find((c) => c.contractId === 'http::PATCH::/orders/{param}');
expect(patchRoute).toBeDefined();
expect(patchRoute?.symbolName).toBe('updateOrder');
});
});
describe('consumer extraction — fetch patterns', () => {
@ -206,6 +289,93 @@ export const deleteUser = (id: string) => axios.delete(\`/api/users/\${id}\`);
consumers.find((c) => c.contractId === 'http::DELETE::/api/users/{param}'),
).toBeDefined();
});
it('extracts Python requests calls', async () => {
const dir = path.join(tmpDir, 'python-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'client.py'),
`
import requests
def create_order():
return requests.post("https://svc.local/api/orders/42", json={"id": 42})
`,
);
const contracts = await extractor.extract(null, dir, makeRepo(dir));
const consumers = contracts.filter((c) => c.role === 'consumer');
expect(
consumers.find((c) => c.contractId === 'http::POST::/api/orders/{param}'),
).toBeDefined();
});
it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => {
const dir = path.join(tmpDir, 'java-consumer');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src', 'ApiClient.java'),
`
import org.springframework.http.HttpMethod;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.reactive.function.client.WebClient;
import okhttp3.Request;
class ApiClient {
void run(RestTemplate restTemplate, WebClient webClient) {
restTemplate.getForObject("/api/users/{id}", String.class, 42);
webClient.method(HttpMethod.PATCH, "/api/users/42");
new Request.Builder().url("/api/orders/42").build();
}
}
`,
);
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::/api/users/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'),
).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'),
).toBeDefined();
});
it('extracts Go stdlib and resty calls', async () => {
const dir = path.join(tmpDir, 'go-consumer');
fs.mkdirSync(path.join(dir, 'cmd'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'cmd', 'client.go'),
`
package main
import (
"net/http"
"github.com/go-resty/resty/v2"
)
func main() {
http.Get("/api/health")
client := resty.New()
client.R().Delete("/api/orders/42")
}
`,
);
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::/api/health')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'),
).toBeDefined();
});
});
describe('provider extraction — Laravel', () => {