From 7b38b8aae2433e33ec5b350d797133ac17bbb0ab Mon Sep 17 00:00:00 2001 From: MyShining <249674729@qq.com> Date: Fri, 29 May 2026 13:05:40 +0800 Subject: [PATCH] feat(java): add HTTP consumer contract extraction (#1872) --- .../group/extractors/http-patterns/java.ts | 260 ++++++++++++++++- .../unit/group/http-route-extractor.test.ts | 276 +++++++++++++++++- 2 files changed, 519 insertions(+), 17 deletions(-) diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index b3ced5920..030b6c9d1 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -11,8 +11,11 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; /** * Java HTTP plugin. Handles: * - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations - * - Spring `RestTemplate.getForObject/...`, `WebClient.method(HttpMethod.X, ...)` + * - Spring `RestTemplate.getForObject/...`, `exchange(...)` + * - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)` * - OkHttp `new Request.Builder().url("...")` + * - OpenFeign interfaces with Spring MVC method annotations + * - Java / Apache HttpClient literal request construction * * The plugin runs two pattern bundles: one to collect class-level * `@RequestMapping` prefixes keyed by the enclosing class node, and a @@ -73,6 +76,53 @@ const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +// ─── Consumer: OpenFeign interface-level prefixes ─────────────────── +// Feign's `name`/`value` attributes identify a service, not an HTTP path, +// so only `path` is used as a URL prefix. `@RequestMapping` on a Feign +// interface is also common and does carry a path prefix. +const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({ + name: 'java-feign-interface-prefix', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "FeignClient") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#eq? @key "path") + value: (string_literal) @prefix))))) @interface + `, + }, + { + meta: {}, + query: ` + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list (string_literal) @prefix)))) @interface + `, + }, + { + meta: {}, + query: ` + (interface_declaration + (modifiers + (annotation + name: (identifier) @ann (#eq? @ann "RequestMapping") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @interface + `, + }, + ], +} satisfies LanguagePatterns>); + // ─── Provider: Spring @(Get|Post|...)Mapping method annotations ─────── // Same dual-pattern approach: positional vs named argument. The named // pattern restricts the annotation member name to `path`/`value` to @@ -116,6 +166,8 @@ const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ // RestTemplate.put → PUT // RestTemplate.delete → DELETE // RestTemplate.patchForObject → PATCH +// Source-scan only: receiver must be named exactly `restTemplate`. +// Fields, `this.restTemplate`, aliases, and other injection names are deferred. const REST_TEMPLATE_TO_HTTP: Record = { getForObject: 'GET', getForEntity: 'GET', @@ -146,22 +198,48 @@ const REST_TEMPLATE_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns); -// ─── Consumer: Spring WebClient — webClient.method(HttpMethod.X, "path") ─ -const WEB_CLIENT_PATTERNS = compilePatterns({ - name: 'java-web-client', +const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({ + name: 'java-rest-template-exchange', + language: Java, + patterns: [ + { + meta: { framework: 'spring-rest-template' }, + query: ` + (method_invocation + object: (identifier) @obj (#eq? @obj "restTemplate") + name: (identifier) @method (#eq? @method "exchange") + arguments: (argument_list + . (string_literal) @path + (field_access + object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") + field: (identifier) @http_method))) + `, + }, + ], +} satisfies LanguagePatterns); + +const WEB_CLIENT_SHORT_TO_HTTP: Record = { + get: 'GET', + post: 'POST', + put: 'PUT', + delete: 'DELETE', + patch: 'PATCH', +}; + +const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({ + name: 'java-web-client-short-form', language: Java, patterns: [ { meta: {}, query: ` (method_invocation - object: (identifier) @obj (#eq? @obj "webClient") - name: (identifier) @method (#eq? @method "method") - arguments: (argument_list - (field_access - object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") - field: (identifier) @http_method) - (string_literal) @path)) + object: (method_invocation + object: (identifier) @obj (#eq? @obj "webClient") + name: (identifier) @verb (#match? @verb "^(get|post|put|delete|patch)$") + arguments: (argument_list)) + name: (identifier) @uri_method (#eq? @uri_method "uri") + arguments: (argument_list . (string_literal) @path)) `, }, ], @@ -188,6 +266,54 @@ const OK_HTTP_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({ + name: 'java-http-client', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (method_invocation + object: (method_invocation + object: (identifier) @builderCls (#eq? @builderCls "HttpRequest") + name: (identifier) @newBuilder (#eq? @newBuilder "newBuilder") + arguments: (argument_list)) + name: (identifier) @uri_method (#eq? @uri_method "uri") + arguments: (argument_list + (method_invocation + object: (identifier) @uriCls (#eq? @uriCls "URI") + name: (identifier) @create (#eq? @create "create") + arguments: (argument_list . (string_literal) @path)))) + name: (identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE)$")) + `, + }, + ], +} satisfies LanguagePatterns>); + +const APACHE_HTTP_CLIENT_TO_HTTP: Record = { + HttpGet: 'GET', + HttpPost: 'POST', + HttpPut: 'PUT', + HttpDelete: 'DELETE', + HttpPatch: 'PATCH', +}; + +const APACHE_HTTP_CLIENT_PATTERNS = compilePatterns({ + name: 'java-apache-http-client', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (object_creation_expression + type: (type_identifier) @type (#match? @type "^Http(Get|Post|Put|Delete|Patch)$") + arguments: (argument_list . (string_literal) @path)) + `, + }, + ], +} satisfies LanguagePatterns>); + /** * Find the nearest enclosing class_declaration ancestor for a node, or * null if the node is top-level. Tree-sitter's SyntaxNode.parent walks @@ -202,6 +328,29 @@ function findEnclosingClass(node: Parser.SyntaxNode): Parser.SyntaxNode | null { return null; } +function findEnclosingInterface(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + let cur: Parser.SyntaxNode | null = node.parent; + while (cur) { + if (cur.type === 'interface_declaration') return cur; + cur = cur.parent; + } + return null; +} + +function hasAnnotation(node: Parser.SyntaxNode, annotationName: string): boolean { + for (const child of node.namedChildren) { + if (child.type !== 'modifiers') continue; + for (const modifier of child.namedChildren) { + if (modifier.type !== 'annotation') continue; + const nameNode = modifier.childForFieldName('name'); + if (!nameNode) continue; + const simpleName = nameNode.text.split('.').pop(); + if (nameNode.text === annotationName || simpleName === annotationName) return true; + } + } + return false; +} + /** * Join a class-level prefix and a method-level path into a single URL * path. Mirrors the semantics of the original regex implementation: @@ -231,6 +380,16 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (prefix !== null) prefixByClassId.set(classNode.id, prefix); } + const feignPrefixByInterfaceId = new Map(); + for (const match of runCompiledPatterns(FEIGN_INTERFACE_PREFIX_PATTERNS, tree)) { + const prefixNode = match.captures.prefix; + const interfaceNode = match.captures.interface; + if (!prefixNode || !interfaceNode) continue; + const prefix = unquoteLiteral(prefixNode.text); + if (prefix !== null && !feignPrefixByInterfaceId.has(interfaceNode.id)) + feignPrefixByInterfaceId.set(interfaceNode.id, prefix); + } + for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { const annNode = match.captures.ann; const pathNode = match.captures.path; @@ -241,6 +400,20 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (!httpMethod) continue; const rawPath = unquoteLiteral(pathNode.text); if (rawPath === null) continue; + const enclosingInterface = findEnclosingInterface(methodNode); + if (enclosingInterface && hasAnnotation(enclosingInterface, 'FeignClient')) { + const prefix = feignPrefixByInterfaceId.get(enclosingInterface.id) ?? ''; + const fullPath = joinPath(prefix, rawPath); + out.push({ + role: 'consumer', + framework: 'openfeign', + method: httpMethod, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.7, + }); + continue; + } const enclosingClass = findEnclosingClass(methodNode); const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; const fullPath = joinPath(prefix, rawPath); @@ -273,8 +446,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } - // ─── Consumers: WebClient.method(HttpMethod.X, "path") ────────── - for (const match of runCompiledPatterns(WEB_CLIENT_PATTERNS, tree)) { + for (const match of runCompiledPatterns(REST_TEMPLATE_EXCHANGE_PATTERNS, tree)) { const httpMethodNode = match.captures.http_method; const pathNode = match.captures.path; if (!httpMethodNode || !pathNode) continue; @@ -282,7 +454,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (path === null) continue; out.push({ role: 'consumer', - framework: 'spring-web-client', + framework: 'spring-rest-template', method: httpMethodNode.text.toUpperCase(), path, name: null, @@ -290,6 +462,28 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // ─── Consumers: WebClient.get().uri("path") short form ───────── + // Source-scan only: receiver must be named exactly `webClient`. + // The real long-form chain `webClient.method(HttpMethod.X).uri("/x")` + // needs multi-hop chain analysis and is intentionally deferred. + for (const match of runCompiledPatterns(WEB_CLIENT_SHORT_FORM_PATTERNS, tree)) { + const verbNode = match.captures.verb; + const pathNode = match.captures.path; + if (!verbNode || !pathNode) continue; + const httpMethod = WEB_CLIENT_SHORT_TO_HTTP[verbNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'spring-web-client', + method: httpMethod, + path, + name: null, + confidence: 0.7, + }); + } + // ─── Consumers: OkHttp Request.Builder().url("path") ──────────── for (const match of runCompiledPatterns(OK_HTTP_PATTERNS, tree)) { const pathNode = match.captures.path; @@ -306,6 +500,44 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + // ─── Consumers: Java HttpClient request builder ───────────────── + // Java's builder exposes GET/POST/PUT/DELETE helpers. PATCH uses + // `.method("PATCH", body)`, which is intentionally deferred. + for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_PATTERNS, tree)) { + const httpMethodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!httpMethodNode || !pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'java-http-client', + method: httpMethodNode.text.toUpperCase(), + path, + name: null, + confidence: 0.65, + }); + } + + // ─── Consumers: Apache HttpClient request constructors ────────── + for (const match of runCompiledPatterns(APACHE_HTTP_CLIENT_PATTERNS, tree)) { + const typeNode = match.captures.type; + const pathNode = match.captures.path; + if (!typeNode || !pathNode) continue; + const httpMethod = APACHE_HTTP_CLIENT_TO_HTTP[typeNode.text]; + if (!httpMethod) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'apache-http-client', + method: httpMethod, + path, + name: null, + confidence: 0.65, + }); + } + return out; }, }; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 8fc6690da..8f4873f2e 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1364,7 +1364,7 @@ shadowed_module_client.get("/module-level-rebind-fp") ).toBeUndefined(); }); - it('extracts Java RestTemplate, WebClient and OkHttp calls', async () => { + it('extracts Java Spring RestTemplate, WebClient and OkHttp literal calls', async () => { const dir = path.join(tmpDir, 'java-consumer'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( @@ -1378,7 +1378,8 @@ 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"); + restTemplate.exchange("/api/users/{id}/details", HttpMethod.GET, null, String.class); + webClient.post().uri("/api/users"); new Request.Builder().url("/api/orders/42").build(); } } @@ -1390,11 +1391,280 @@ class ApiClient { expect(consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}')).toBeDefined(); expect( - consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + consumers.find((c) => c.contractId === 'http::GET::/api/users/{param}/details'), ).toBeDefined(); expect( consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/users/{param}/details' && + c.meta.framework === 'spring-rest-template' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/api/users' && + c.meta.framework === 'spring-web-client' && + c.confidence === 0.7, + ), + ).toBeDefined(); + }); + + it('does NOT match Java WebClient long-form method(HttpMethod).uri(...) yet', async () => { + const dir = path.join(tmpDir, 'java-web-client-long-form'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'LongFormClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClient; + +class LongFormClient { + void run(WebClient webClient) { + webClient.method(HttpMethod.PATCH).uri("/api/users/42").retrieve(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + ).toBeUndefined(); + }); + + it('extracts OpenFeign clients as consumers, not providers', async () => { + const dir = path.join(tmpDir, 'java-openfeign-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OrderClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@FeignClient(name = "order-service", url = "\${order.service.url}", path = "/api") +interface OrderClient { + @GetMapping("/orders/{id}") + OrderDto getOrder(@PathVariable("id") String id); + + @PostMapping(path = "/orders") + OrderDto createOrder(OrderDto body); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/api/orders' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + providers.find((c) => c.symbolRef.filePath.endsWith('OrderClient.java')), + ).toBeUndefined(); + }); + + it('extracts OpenFeign clients without an interface path prefix', async () => { + const dir = path.join(tmpDir, 'java-openfeign-no-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'HealthClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "health-service") +interface HealthClient { + @GetMapping("/health") + String health(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/health' && + c.meta.framework === 'openfeign' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + providers.find((c) => c.symbolRef.filePath.endsWith('HealthClient.java')), + ).toBeUndefined(); + }); + + it('does not treat @FeignClient text in an interface body as a Feign annotation', async () => { + const dir = path.join(tmpDir, 'java-non-feign-interface-text'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'NotFeignClient.java'), + ` +import org.springframework.web.bind.annotation.GetMapping; + +interface NotFeignClient { + String MARKER = "@FeignClient"; + + @GetMapping("/not-feign") + String call(); +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const providers = contracts.filter((c) => c.role === 'provider'); + + expect(consumers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeUndefined(); + expect(providers.find((c) => c.contractId === 'http::GET::/not-feign')).toBeDefined(); + }); + + it('extracts OpenFeign clients with @RequestMapping interface prefixes', async () => { + const dir = path.join(tmpDir, 'java-openfeign-request-mapping-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'InventoryClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@FeignClient(name = "inventory-service") +@RequestMapping(path = "/api") +interface InventoryClient { + @GetMapping("/inventory/{id}") + InventoryDto getInventory(String id); +} +`, + ); + + 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/inventory/{param}' && + c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + }); + + it('prefers @FeignClient(path=...) over @RequestMapping prefixes on OpenFeign clients', async () => { + const dir = path.join(tmpDir, 'java-openfeign-prefix-precedence'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'PrecedenceClient.java'), + ` +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +@FeignClient(name = "order-service", path = "/feign-path") +@RequestMapping("/rm-path") +interface PrecedenceClient { + @GetMapping("/orders") + OrderDto getOrders(); +} +`, + ); + + 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::/feign-path/orders')).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/rm-path/orders')).toBeUndefined(); + }); + + it('extracts Java and Apache HttpClient literal request construction', async () => { + const dir = path.join(tmpDir, 'java-http-client-consumer'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'HttpClients.java'), + ` +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpPatch; + +class HttpClients { + void run(HttpClient client) throws Exception { + HttpRequest get = HttpRequest.newBuilder() + .uri(URI.create("/api/users/1")) + .GET() + .build(); + HttpRequest post = HttpRequest.newBuilder() + .uri(URI.create("/api/users")) + .POST(HttpRequest.BodyPublishers.ofString("{}")) + .build(); + + new HttpGet("/api/orders/2"); + new HttpPost("/api/orders"); + new HttpPut("/api/orders/3"); + new HttpDelete("/api/orders/4"); + new HttpPatch("/api/orders/5"); + } +} +`, + ); + + 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::POST::/api/users' && + c.meta.framework === 'java-http-client' && + c.confidence === 0.65, + ), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::POST::/api/orders' && + c.meta.framework === 'apache-http-client' && + c.confidence === 0.65, + ), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PUT::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::DELETE::/api/orders/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::PATCH::/api/orders/{param}'), + ).toBeDefined(); }); // ─── Kotlin consumers (RestTemplate / WebClient short / OkHttp) ──