mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-30 01:51:20 +00:00
Merge branch 'main' into feat/multi-cli-devcontainer
This commit is contained in:
commit
25dd0234dd
2 changed files with 519 additions and 17 deletions
|
|
@ -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<Record<string, never>>);
|
||||
|
||||
// ─── 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<Record<string, never>>);
|
||||
|
||||
// ─── 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<string, string> = {
|
||||
getForObject: 'GET',
|
||||
getForEntity: 'GET',
|
||||
|
|
@ -146,22 +198,48 @@ const REST_TEMPLATE_PATTERNS = compilePatterns({
|
|||
],
|
||||
} satisfies LanguagePatterns<RestTemplateMeta>);
|
||||
|
||||
// ─── 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<RestTemplateMeta>);
|
||||
|
||||
const WEB_CLIENT_SHORT_TO_HTTP: Record<string, string> = {
|
||||
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<Record<string, never>>);
|
||||
|
||||
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<Record<string, never>>);
|
||||
|
||||
const APACHE_HTTP_CLIENT_TO_HTTP: Record<string, string> = {
|
||||
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<Record<string, never>>);
|
||||
|
||||
/**
|
||||
* 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<number, string>();
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) ──
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue