diff --git a/gitnexus/src/core/group/extractors/http-patterns/java.ts b/gitnexus/src/core/group/extractors/http-patterns/java.ts index 030b6c9d19..d1079444a7 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/java.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/java.ts @@ -12,7 +12,7 @@ import type { HttpDetection, HttpLanguagePlugin } from './types.js'; * Java HTTP plugin. Handles: * - Spring `@RequestMapping` class prefixes + `@(Get|Post|...)Mapping` method annotations * - Spring `RestTemplate.getForObject/...`, `exchange(...)` - * - Spring `WebClient.method(HttpMethod.X, ...)`, `WebClient.get().uri(...)` + * - Spring `WebClient.get().uri(...)`, `WebClient.method(HttpMethod.X).uri(...)` * - OkHttp `new Request.Builder().url("...")` * - OpenFeign interfaces with Spring MVC method annotations * - Java / Apache HttpClient literal request construction @@ -56,7 +56,7 @@ const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ (class_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") + name: (_) @ann (#match? @ann "(^|\\\\.)RequestMapping$") arguments: (annotation_argument_list (string_literal) @prefix)))) @class `, }, @@ -66,7 +66,7 @@ const SPRING_CLASS_PREFIX_PATTERNS = compilePatterns({ (class_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") + name: (_) @ann (#match? @ann "(^|\\\\.)RequestMapping$") arguments: (annotation_argument_list (element_value_pair key: (identifier) @key (#match? @key "^(path|value)$") @@ -76,12 +76,8 @@ 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', +const SPRING_INTERFACE_PREFIX_PATTERNS = compilePatterns({ + name: 'java-spring-interface-prefix', language: Java, patterns: [ { @@ -90,11 +86,8 @@ const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({ (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 + name: (_) @ann (#match? @ann "(^|\\\\.)RequestMapping$") + arguments: (annotation_argument_list (string_literal) @prefix)))) @interface `, }, { @@ -103,20 +96,34 @@ const FEIGN_INTERFACE_PREFIX_PATTERNS = compilePatterns({ (interface_declaration (modifiers (annotation - name: (identifier) @ann (#eq? @ann "RequestMapping") - arguments: (annotation_argument_list (string_literal) @prefix)))) @interface + name: (_) @ann (#match? @ann "(^|\\\\.)RequestMapping$") + arguments: (annotation_argument_list + (element_value_pair + key: (identifier) @key (#match? @key "^(path|value)$") + value: (string_literal) @prefix))))) @interface `, }, + ], +} 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 "RequestMapping") + name: (_) @ann (#match? @ann "(^|\\\\.)FeignClient$") arguments: (annotation_argument_list (element_value_pair - key: (identifier) @key (#match? @key "^(path|value)$") + key: (identifier) @key (#eq? @key "path") value: (string_literal) @prefix))))) @interface `, }, @@ -138,7 +145,7 @@ const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ (method_declaration (modifiers (annotation - name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") + name: (_) @ann (#match? @ann "(^|\\\\.)(Get|Post|Put|Delete|Patch)Mapping$") arguments: (annotation_argument_list (string_literal) @path))) name: (identifier) @method_name) @method `, @@ -149,7 +156,7 @@ const SPRING_METHOD_ROUTE_PATTERNS = compilePatterns({ (method_declaration (modifiers (annotation - name: (identifier) @ann (#match? @ann "^(Get|Post|Put|Delete|Patch)Mapping$") + name: (_) @ann (#match? @ann "(^|\\\\.)(Get|Post|Put|Delete|Patch)Mapping$") arguments: (annotation_argument_list (element_value_pair key: (identifier) @key (#match? @key "^(path|value)$") @@ -192,7 +199,7 @@ const REST_TEMPLATE_PATTERNS = compilePatterns({ (method_invocation object: (identifier) @obj (#eq? @obj "restTemplate") name: (identifier) @method - arguments: (argument_list . (string_literal) @path)) + arguments: (argument_list . (_) @path)) `, }, ], @@ -209,7 +216,7 @@ const REST_TEMPLATE_EXCHANGE_PATTERNS = compilePatterns({ object: (identifier) @obj (#eq? @obj "restTemplate") name: (identifier) @method (#eq? @method "exchange") arguments: (argument_list - . (string_literal) @path + . (_) @path (field_access object: (identifier) @httpMethodCls (#eq? @httpMethodCls "HttpMethod") field: (identifier) @http_method))) @@ -226,6 +233,8 @@ const WEB_CLIENT_SHORT_TO_HTTP: Record = { patch: 'PATCH', }; +const WEB_CLIENT_LONG_METHODS = new Set(Object.values(WEB_CLIENT_SHORT_TO_HTTP)); + const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({ name: 'java-web-client-short-form', language: Java, @@ -245,6 +254,29 @@ const WEB_CLIENT_SHORT_FORM_PATTERNS = compilePatterns({ ], } satisfies LanguagePatterns>); +const WEB_CLIENT_LONG_FORM_PATTERNS = compilePatterns({ + name: 'java-web-client-long-form', + language: Java, + patterns: [ + { + meta: {}, + query: ` + (method_invocation + object: (method_invocation + object: (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))) + name: (identifier) @uri_method (#eq? @uri_method "uri") + arguments: (argument_list . (string_literal) @path))) + `, + }, + ], +} satisfies LanguagePatterns>); + // ─── Consumer: OkHttp `new Request.Builder().url("path")` ───────────── // Note: `Request.Builder` is a `scoped_type_identifier` whose text includes // the dot, so `#eq?` against the literal string matches cleanly (no need @@ -260,7 +292,7 @@ const OK_HTTP_PATTERNS = compilePatterns({ object: (object_creation_expression type: (scoped_type_identifier) @type (#eq? @type "Request.Builder")) name: (identifier) @method (#eq? @method "url") - arguments: (argument_list . (string_literal) @path)) + arguments: (argument_list . (string_literal) @path)) @call `, }, ], @@ -285,7 +317,59 @@ const JAVA_HTTP_CLIENT_PATTERNS = compilePatterns({ 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)$")) + name: (identifier) @http_method (#match? @http_method "^(GET|POST|PUT|DELETE|HEAD)$")) + `, + }, + ], +} satisfies LanguagePatterns>); + +const JAVA_HTTP_CLIENT_METHOD_PATTERNS = compilePatterns({ + name: 'java-http-client-method', + 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) @method (#eq? @method "method") + arguments: (argument_list . (string_literal) @http_method)) + `, + }, + ], +} satisfies LanguagePatterns>); + +const JAVA_HTTP_CLIENT_DEFAULT_GET_PATTERNS = compilePatterns({ + name: 'java-http-client-default-get', + 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) @build (#eq? @build "build") + arguments: (argument_list)) `, }, ], @@ -351,6 +435,103 @@ function hasAnnotation(node: Parser.SyntaxNode, annotationName: string): boolean return false; } +function simpleName(text: string): string { + return text.split('.').pop() ?? text; +} + +function methodInvocationName(node: Parser.SyntaxNode): string | null { + return node.type === 'method_invocation' ? (node.childForFieldName('name')?.text ?? null) : null; +} + +function methodInvocationObject(node: Parser.SyntaxNode): Parser.SyntaxNode | null { + return node.type === 'method_invocation' ? node.childForFieldName('object') : null; +} + +function methodInvocationArguments(node: Parser.SyntaxNode): Parser.SyntaxNode[] { + const argsNode = node.type === 'method_invocation' ? node.childForFieldName('arguments') : null; + return argsNode?.namedChildren ?? []; +} + +function firstLiteralArgument(node: Parser.SyntaxNode): string | null { + const first = methodInvocationArguments(node)[0]; + return first?.type === 'string_literal' ? unquoteLiteral(first.text) : null; +} + +function appendPath(base: string, subPath: string): string { + if (!base) return subPath.startsWith('/') ? subPath : `/${subPath}`; + if (!subPath) return base; + return `${base.replace(/\/+$/, '')}/${subPath.replace(/^\/+/, '')}`; +} + +function extractUriCreatePath(node: Parser.SyntaxNode): string | null { + if (node.type !== 'method_invocation') return null; + const objectNode = methodInvocationObject(node); + if (objectNode?.text !== 'URI' || methodInvocationName(node) !== 'create') return null; + return firstLiteralArgument(node); +} + +function extractUriComponentsBuilderPath(node: Parser.SyntaxNode): string | null { + if (node.type !== 'method_invocation') return null; + const name = methodInvocationName(node); + const objectNode = methodInvocationObject(node); + if ( + (name === 'fromPath' || name === 'fromUriString' || name === 'fromHttpUrl') && + objectNode?.text === 'UriComponentsBuilder' + ) + return firstLiteralArgument(node); + if (!objectNode) return null; + if (name === 'path') { + const base = extractUriComponentsBuilderPath(objectNode); + const subPath = firstLiteralArgument(node); + return base !== null && subPath !== null ? appendPath(base, subPath) : null; + } + if (name === 'pathSegment') { + const base = extractUriComponentsBuilderPath(objectNode); + if (base === null) return null; + const segments = methodInvocationArguments(node) + .map((arg) => (arg.type === 'string_literal' ? unquoteLiteral(arg.text) : null)) + .filter((segment): segment is string => segment !== null); + if (segments.length !== methodInvocationArguments(node).length) return null; + return segments.reduce((acc, segment) => appendPath(acc, segment), base); + } + if ( + name === 'build' || + name === 'toUriString' || + name === 'toUri' || + name === 'encode' || + name === 'query' || + name === 'queryParam' || + name === 'queryParams' || + name === 'replaceQuery' || + name === 'replaceQueryParam' || + name === 'replaceQueryParams' + ) + return extractUriComponentsBuilderPath(objectNode); + return null; +} + +function extractStaticPathExpression(node: Parser.SyntaxNode): string | null { + if (node.type === 'string_literal') return unquoteLiteral(node.text); + return extractUriCreatePath(node) ?? extractUriComponentsBuilderPath(node); +} + +function inferOkHttpMethod(urlCall: Parser.SyntaxNode): string { + let cur: Parser.SyntaxNode = urlCall; + let parent = cur.parent; + while (parent?.type === 'method_invocation' && methodInvocationObject(parent)?.id === cur.id) { + const name = methodInvocationName(parent); + if (name && ['get', 'head', 'post', 'put', 'delete', 'patch'].includes(name)) + return name.toUpperCase(); + if (name === 'method') { + const method = firstLiteralArgument(parent); + return method?.toUpperCase() ?? 'GET'; + } + cur = parent; + parent = parent.parent; + } + return 'GET'; +} + /** * Join a class-level prefix and a method-level path into a single URL * path. Mirrors the semantics of the original regex implementation: @@ -381,13 +562,21 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { } const feignPrefixByInterfaceId = new Map(); + const prefixByInterfaceId = new Map(); + for (const match of runCompiledPatterns(SPRING_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) prefixByInterfaceId.set(interfaceNode.id, prefix); + } + 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); + if (prefix !== null) feignPrefixByInterfaceId.set(interfaceNode.id, prefix); } for (const match of runCompiledPatterns(SPRING_METHOD_ROUTE_PATTERNS, tree)) { @@ -396,13 +585,16 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { const nameNode = match.captures.method_name; const methodNode = match.captures.method; if (!annNode || !pathNode || !methodNode) continue; - const httpMethod = METHOD_ANNOTATION_TO_HTTP[annNode.text]; + const httpMethod = METHOD_ANNOTATION_TO_HTTP[simpleName(annNode.text)]; 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 prefix = + feignPrefixByInterfaceId.get(enclosingInterface.id) ?? + prefixByInterfaceId.get(enclosingInterface.id) ?? + ''; const fullPath = joinPath(prefix, rawPath); out.push({ role: 'consumer', @@ -414,6 +606,19 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); continue; } + if (enclosingInterface) { + const prefix = prefixByInterfaceId.get(enclosingInterface.id) ?? ''; + const fullPath = joinPath(prefix, rawPath); + out.push({ + role: 'provider', + framework: 'spring', + method: httpMethod, + path: fullPath, + name: nameNode?.text ?? null, + confidence: 0.8, + }); + continue; + } const enclosingClass = findEnclosingClass(methodNode); const prefix = enclosingClass ? (prefixByClassId.get(enclosingClass.id) ?? '') : ''; const fullPath = joinPath(prefix, rawPath); @@ -434,7 +639,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { if (!methodNode || !pathNode) continue; const httpMethod = REST_TEMPLATE_TO_HTTP[methodNode.text]; if (!httpMethod) continue; - const path = unquoteLiteral(pathNode.text); + const path = extractStaticPathExpression(pathNode); if (path === null) continue; out.push({ role: 'consumer', @@ -450,7 +655,7 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { const httpMethodNode = match.captures.http_method; const pathNode = match.captures.path; if (!httpMethodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = extractStaticPathExpression(pathNode); if (path === null) continue; out.push({ role: 'consumer', @@ -464,8 +669,6 @@ 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; @@ -484,16 +687,35 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + for (const match of runCompiledPatterns(WEB_CLIENT_LONG_FORM_PATTERNS, tree)) { + const httpMethodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!httpMethodNode || !pathNode) continue; + const httpMethod = httpMethodNode.text.toUpperCase(); + if (!WEB_CLIENT_LONG_METHODS.has(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 callNode = match.captures.call; const pathNode = match.captures.path; - if (!pathNode) continue; + if (!callNode || !pathNode) continue; const path = unquoteLiteral(pathNode.text); if (path === null) continue; out.push({ role: 'consumer', framework: 'okhttp', - method: 'GET', + method: inferOkHttpMethod(callNode), path, name: null, confidence: 0.7, @@ -501,8 +723,8 @@ 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. + // Java's standard builder exposes GET/POST/PUT/DELETE/HEAD helpers. + // Other verbs, including PATCH, use `.method("PATCH", body)`. for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_PATTERNS, tree)) { const httpMethodNode = match.captures.http_method; const pathNode = match.captures.path; @@ -519,6 +741,38 @@ export const JAVA_HTTP_PLUGIN: HttpLanguagePlugin = { }); } + for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_METHOD_PATTERNS, tree)) { + const httpMethodNode = match.captures.http_method; + const pathNode = match.captures.path; + if (!httpMethodNode || !pathNode) continue; + const httpMethod = unquoteLiteral(httpMethodNode.text); + const path = unquoteLiteral(pathNode.text); + if (httpMethod === null || path === null) continue; + out.push({ + role: 'consumer', + framework: 'java-http-client', + method: httpMethod.toUpperCase(), + path, + name: null, + confidence: 0.65, + }); + } + + for (const match of runCompiledPatterns(JAVA_HTTP_CLIENT_DEFAULT_GET_PATTERNS, tree)) { + const pathNode = match.captures.path; + if (!pathNode) continue; + const path = unquoteLiteral(pathNode.text); + if (path === null) continue; + out.push({ + role: 'consumer', + framework: 'java-http-client', + method: 'GET', + 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; diff --git a/gitnexus/test/unit/group/http-route-extractor.test.ts b/gitnexus/test/unit/group/http-route-extractor.test.ts index 8f4873f2ed..7c3293c4b2 100644 --- a/gitnexus/test/unit/group/http-route-extractor.test.ts +++ b/gitnexus/test/unit/group/http-route-extractor.test.ts @@ -1370,6 +1370,8 @@ shadowed_module_client.get("/module-level-rebind-fp") fs.writeFileSync( path.join(dir, 'src', 'ApiClient.java'), ` +import java.net.URI; +import org.springframework.web.util.UriComponentsBuilder; import org.springframework.http.HttpMethod; import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; @@ -1377,8 +1379,21 @@ import okhttp3.Request; class ApiClient { void run(RestTemplate restTemplate, WebClient webClient) { + String dynamicPath = "/api/dynamic-users/99"; restTemplate.getForObject("/api/users/{id}", String.class, 42); restTemplate.exchange("/api/users/{id}/details", HttpMethod.GET, null, String.class); + restTemplate.getForObject(dynamicPath, String.class); + restTemplate.getForEntity(URI.create("/api/uri-users/42"), String.class); + restTemplate.exchange(URI.create("/api/uri-users/42/details"), HttpMethod.POST, null, String.class); + restTemplate.getForObject( + UriComponentsBuilder.fromPath("/api").path("/builder-users").pathSegment("42").build().toUriString(), + String.class); + restTemplate.getForObject( + UriComponentsBuilder.fromUriString("/base").path("/sub").queryParam("page", "1").build().toUriString(), + String.class); + restTemplate.getForObject( + UriComponentsBuilder.fromHttpUrl("https://example.com/api").path("/external-users").query("page=1").build().toUriString(), + String.class); webClient.post().uri("/api/users"); new Request.Builder().url("/api/orders/42").build(); } @@ -1404,6 +1419,22 @@ class ApiClient { c.confidence === 0.7, ), ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/uri-users/{param}'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::POST::/api/uri-users/{param}/details'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/builder-users/{param}'), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::GET::/base/sub')).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/external-users'), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/dynamic-users/{param}'), + ).toBeUndefined(); expect( consumers.find( (c) => @@ -1414,7 +1445,7 @@ class ApiClient { ).toBeDefined(); }); - it('does NOT match Java WebClient long-form method(HttpMethod).uri(...) yet', async () => { + it('extracts Java WebClient long-form method(HttpMethod).uri(...) calls', async () => { const dir = path.join(tmpDir, 'java-web-client-long-form'); fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); fs.writeFileSync( @@ -1435,8 +1466,136 @@ class LongFormClient { const consumers = contracts.filter((c) => c.role === 'consumer'); expect( - consumers.find((c) => c.contractId === 'http::PATCH::/api/users/{param}'), + consumers.find( + (c) => + c.contractId === 'http::PATCH::/api/users/{param}' && + c.meta.framework === 'spring-web-client' && + c.confidence === 0.7, + ), + ).toBeDefined(); + }); + + it('does not double-emit Java WebClient long-form calls', async () => { + const dir = path.join(tmpDir, 'java-web-client-long-form-no-double'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'NoDoubleClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClient; + +class NoDoubleClient { + void run(WebClient webClient) { + webClient.method(HttpMethod.GET).uri("/api/single").retrieve(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const fromThisFile = consumers.filter((c) => + c.symbolRef.filePath.endsWith('NoDoubleClient.java'), + ); + + expect(fromThisFile).toHaveLength(1); + expect(fromThisFile[0].contractId).toBe('http::GET::/api/single'); + }); + + it('does not guess Java WebClient long-form variable-bound or unsupported verbs', async () => { + const dir = path.join(tmpDir, 'java-web-client-long-form-boundary'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'BoundaryClient.java'), + ` +import org.springframework.http.HttpMethod; +import org.springframework.web.reactive.function.client.WebClient; + +class BoundaryClient { + void run(WebClient webClient) { + HttpMethod verb = HttpMethod.PATCH; + webClient.method(verb).uri("/api/dynamic").retrieve(); + webClient.method(HttpMethod.HEAD).uri("/api/head").retrieve(); + } +} +`, + ); + + const contracts = await extractor.extract(null, dir, makeRepo(dir)); + const consumers = contracts.filter((c) => c.role === 'consumer'); + const fromThisFile = consumers.filter((c) => + c.symbolRef.filePath.endsWith('BoundaryClient.java'), + ); + + expect(fromThisFile).toHaveLength(0); + }); + + it('extracts fully-qualified Java Spring annotations', async () => { + const dir = path.join(tmpDir, 'java-fully-qualified-spring-annotations'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'QualifiedClient.java'), + ` +@org.springframework.cloud.openfeign.FeignClient(name = "order-service", path = "/api") +interface QualifiedClient { + @org.springframework.web.bind.annotation.GetMapping("/orders/{id}") + OrderDto getOrder(String id); +} + +@org.springframework.web.bind.annotation.RequestMapping(path = "/v1") +class QualifiedController { + @org.springframework.web.bind.annotation.PostMapping(path = "/orders") + void createOrder() {} +} +`, + ); + + 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}' && c.meta.framework === 'openfeign', + ), + ).toBeDefined(); + expect( + providers.find( + (c) => c.contractId === 'http::POST::/v1/orders' && c.meta.framework === 'spring', + ), + ).toBeDefined(); + }); + + it('keeps Spring controller interface prefixes for non-Feign providers', async () => { + const dir = path.join(tmpDir, 'java-spring-controller-interface-prefix'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'UsersApi.java'), + ` +@org.springframework.web.bind.annotation.RequestMapping("/api") +interface UsersApi { + @org.springframework.web.bind.annotation.GetMapping("/users/{id}") + UserDto getUser(String id); +} +`, + ); + + 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/users/{param}'), ).toBeUndefined(); + expect( + providers.find( + (c) => + c.contractId === 'http::GET::/api/users/{param}' && + c.meta.framework === 'spring' && + c.confidence === 0.8, + ), + ).toBeDefined(); }); it('extracts OpenFeign clients as consumers, not providers', async () => { @@ -1577,14 +1736,10 @@ interface InventoryClient { 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") +@org.springframework.cloud.openfeign.FeignClient(name = "order-service", path = "/feign-path") +@org.springframework.web.bind.annotation.RequestMapping("/rm-path") interface PrecedenceClient { - @GetMapping("/orders") + @org.springframework.web.bind.annotation.GetMapping("/orders") OrderDto getOrders(); } `, @@ -1622,6 +1777,17 @@ class HttpClients { .uri(URI.create("/api/users")) .POST(HttpRequest.BodyPublishers.ofString("{}")) .build(); + HttpRequest head = HttpRequest.newBuilder() + .uri(URI.create("/api/users/head")) + .HEAD() + .build(); + HttpRequest patch = HttpRequest.newBuilder() + .uri(URI.create("/api/users/2")) + .method("PATCH", HttpRequest.BodyPublishers.ofString("{}")) + .build(); + HttpRequest defaultGet = HttpRequest.newBuilder() + .uri(URI.create("/api/default-users/3")) + .build(); new HttpGet("/api/orders/2"); new HttpPost("/api/orders"); @@ -1645,6 +1811,29 @@ class HttpClients { c.confidence === 0.65, ), ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::PATCH::/api/users/{param}' && + c.meta.framework === 'java-http-client' && + c.confidence === 0.65, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::HEAD::/api/users/head' && + c.meta.framework === 'java-http-client' && + c.confidence === 0.65, + ), + ).toBeDefined(); + expect( + consumers.find( + (c) => + c.contractId === 'http::GET::/api/default-users/{param}' && + c.meta.framework === 'java-http-client', + ), + ).toBeDefined(); expect( consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), ).toBeDefined(); @@ -1667,6 +1856,55 @@ class HttpClients { ).toBeDefined(); }); + it('infers Java OkHttp verbs from sibling Request.Builder calls', async () => { + const dir = path.join(tmpDir, 'java-okhttp-verbs'); + fs.mkdirSync(path.join(dir, 'src'), { recursive: true }); + fs.writeFileSync( + path.join(dir, 'src', 'OkHttpVerbs.java'), + ` +import okhttp3.Request; +import okhttp3.RequestBody; + +class OkHttpVerbs { + void run(RequestBody body) { + new Request.Builder().url("/api/orders/0").get().build(); + new Request.Builder().url("/api/orders/head").head().build(); + new Request.Builder().url("/api/orders").post(body).build(); + new Request.Builder().url("/api/orders/1").put(body).build(); + new Request.Builder().url("/api/orders/2").delete().build(); + new Request.Builder().url("/api/orders/3").method("GET", null).build(); + new Request.Builder().url("/api/orders/3").method("PATCH", body).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::POST::/api/orders' && + c.meta.framework === 'okhttp' && + c.confidence === 0.7, + ), + ).toBeDefined(); + expect( + consumers.find((c) => c.contractId === 'http::GET::/api/orders/{param}'), + ).toBeDefined(); + expect(consumers.find((c) => c.contractId === 'http::HEAD::/api/orders/head')).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) ── // Same shape as the Java consumer test above, but parsed by the // tree-sitter-kotlin grammar via `KOTLIN_HTTP_PLUGIN`. Three