feat(node): wrapped-client HTTP consumers + leading-prefix template stripping (#3111)

* feat(http-patterns): Patch 8 - extract enterprise wrapped-client HTTP consumers

Recognize the enterprise axios-wrapper call shape X.request({ url, method })
(e.g. httpClient.request from @winex-plugin/win-request) as a consumer
contract source, and strip the leading gateway/service-prefix template
variable so consumer paths align with backend provider routes.

Effect on sr-next group: 1740 contracts / 0 cross-links ->
3540 contracts / 883 exact cross-links (14 frontend repos <-> backend/opt).

Not submitted upstream yet; see CUSTOM_PATCHES.md Patch 8 for details.

Co-Authored-By: Claude <noreply@anthropic.com>

* feat(node): wrapped-client HTTP consumers + leading-prefix template stripping

Consumer extraction for enterprise axios-wrapped clients:
- X.request({ url, method }) member form (httpClient.request from
  win-request and friends): any .request(options) call with url/method|type
  string props; the url template literal is split on ${...} spans and the
  longest /-leading literal segment is kept.
- Leading ${...} gateway/service-prefix variables are stripped as
  consumer-path normalization semantics in normalizeHttpPath
  (stripLeadingTemplatePrefix): `${client}/api/v1/x` → /api/v1/x for
  fetch/axios/wrapped shapes alike. Mid/tail interpolations still round-trip
  through {param}; a stripped remainder not starting with / is dropped
  (same rejection static relative urls get at scan time).
- %7B/%7D unescaping for absolute-URL branches.

On our 16-repo frontend monorepo this took group sync from 1740
contracts / 0 cross-links to 5111 / 2097 (exact links, 16/16 repos linked).

* fix(route): preserve upstream symbol-resolution machinery; strip only

Rebasing correction: an earlier iteration of this change simplified the
symbol probing to a bare line-1 offset and dropped the exact-module match,
which mis-attributed data-table handlers to decoy same-name symbols
(6 data-route-table regressions). Restore the upstream probing
(toZeroBasedLine + exact-module resolution) wholesale; the deltas this
change actually needs are the pure ones: stripLeadingTemplatePrefix,
normalizeConsumerPath returning null for un-reducible urls (callers
drop), and the %7B/%7D brace restoration in the absolute-URL branch.

* style: prettier

* Address PR review feedback (#3111)

Pass wrapped-client URLs through shared consumer-path normalization instead of the longest-segment reducer, emit * for present-but-non-literal methods, restore {param} via a sentinel so literal %7B segments stay encoded, and drop unused decorator helpers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3111)

Gate wrapped X.request({url}) on axios-proven receivers or a small wrapper allowlist so cy.request/queue.request cannot mint HTTP consumers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3111)

Use a private-use sentinel so literal path segments are not rewritten, trim wrapped URLs before the scan-time path gate, and treat interpolated/shorthand methods as *.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(autofix): apply prettier + eslint fixes via /autofix command

* fix(http): honest wrapped-request methods and tighter admission (#3111)

Quoted keys and object spreads were minted as GET; drop spelling-only `api`,
align gateway-prefix member verbs with prefix-strip, and keep absolute URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: l.cx <l.cx@winning.com.cn>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: ChunxueLi <mecoloud@users.noreply.gitee.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
ChunxueLi 2026-09-03 03:59:45 +08:00 committed by GitHub
parent 3aa62be717
commit b9613ee86b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 511 additions and 13 deletions

View file

@ -13,6 +13,7 @@ import type { HttpDetection, HttpLanguagePlugin, RepoContext } from './types.js'
import { MAX_FOLD_LENGTH } from '../../../ingestion/route-extractors/constant-resolver.js';
import {
DATA_ROUTE_TABLE_SOURCE,
propertyName,
scanDataRouteTables,
} from '../../../ingestion/route-extractors/data-route-table.js';
import { extractNestRoutes } from '../../../ingestion/route-extractors/nest.js';
@ -152,6 +153,37 @@ const AXIOS_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
`,
};
// ─── Consumer: wrapped client X.request({ url, method }) ────────────
// Enterprise wrapper shape: an axios instance (or a named request helper)
// re-exported under a local name — `httpClient.request({ url, method })`
// from `@winex-plugin/win-request`, `$http.request(...)`. Generic names
// like `api` need axios.create/import proof (`isHttpClientRef`); spelling
// alone is too common (graphql-request helpers, domain `api` objects).
// The member property is `request` (not an HTTP verb), so this cannot
// collide with the Express provider pattern (`router.get`) or the axios
// member form (`axios.get`). Option keys are resolved programmatically,
// same as the jQuery ajax / axios object forms.
//
// The query captures the receiver so scan can reject unrelated
// `.request({ url })` APIs (`cy.request`, `queue.request`).
const REQUEST_OBJECT_SPEC: PatternSpec<Record<string, never>> = {
meta: {},
query: `
(call_expression
function: (member_expression
object: (_) @obj
property: (property_identifier) @fn (#eq? @fn "request"))
arguments: (arguments . (object) @options))
`,
};
/**
* Receivers admitted as wrapped HTTP clients without axios.create proof.
* Spelling-only: the last identifier in `obj.text` (`this.$http` `$http`).
* Keep this set small every extra name is a false-positive surface.
*/
const WRAPPED_REQUEST_RECEIVERS = new Set(['httpClient', '$http']);
interface NodePatternBundle {
express: CompiledPatterns<Record<string, never>>;
fetchNoOptions: CompiledPatterns<Record<string, never>>;
@ -160,6 +192,7 @@ interface NodePatternBundle {
jqueryShorthand: CompiledPatterns<Record<string, never>>;
jqueryAjax: CompiledPatterns<Record<string, never>>;
axiosObject: CompiledPatterns<Record<string, never>>;
requestObject: CompiledPatterns<Record<string, never>>;
}
function compileBundle(language: unknown, name: string): NodePatternBundle {
@ -177,6 +210,7 @@ function compileBundle(language: unknown, name: string): NodePatternBundle {
jqueryShorthand: mk(JQUERY_SHORTHAND_SPEC, 'jquery-shorthand'),
jqueryAjax: mk(JQUERY_AJAX_SPEC, 'jquery-ajax'),
axiosObject: mk(AXIOS_OBJECT_SPEC, 'axios-object'),
requestObject: mk(REQUEST_OBJECT_SPEC, 'request-object'),
};
}
@ -190,6 +224,7 @@ const TSX_BUNDLE = compileBundle(TypeScript.tsx, 'tsx-http');
* of `keyNames`. Returns null when no matching pair is present or the
* value is not a string literal. Used by the jQuery ajax / axios object
* consumers to resolve `url` / `method` / `type` keys in any order.
* Keys use shared `propertyName` so quoted `"method"` matches `method`.
*/
function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string[]): string | null {
for (let i = 0; i < objectNode.namedChildCount; i++) {
@ -198,7 +233,8 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
const keyNode = pair.childForFieldName('key');
const valueNode = pair.childForFieldName('value');
if (!keyNode || !valueNode) continue;
if (!keyNames.includes(keyNode.text)) continue;
const key = propertyName(keyNode);
if (key === null || !keyNames.includes(key)) continue;
if (valueNode.type !== 'string' && valueNode.type !== 'template_string') continue;
const lit = unquoteLiteral(valueNode.text);
if (lit !== null) return lit;
@ -206,6 +242,80 @@ function readStringProp(objectNode: Parser.SyntaxNode, keyNames: readonly string
return null;
}
/**
* Verb for wrapped `X.request({ url, method|type })`. Absent key GET
* (same default as fetch-without-options / jQuery ajax). Present but not a
* string/template, supplied only via object spread, or later overwritten by
* a duplicate key / spread `*` so matching can still link without pinning GET.
* Later properties win, matching JavaScript object-literal evaluation.
*/
function readRequestMethod(
objectNode: Parser.SyntaxNode,
keyNames: readonly string[] = ['method', 'type'],
): string {
type Verb = { kind: 'absent' } | { kind: 'literal'; value: string } | { kind: 'unknown' };
let last: Verb = { kind: 'absent' };
for (let i = 0; i < objectNode.namedChildCount; i++) {
const child = objectNode.namedChild(i);
if (!child) continue;
if (child.type === 'spread_element') {
last = { kind: 'unknown' };
continue;
}
if (
child.type === 'shorthand_property_identifier' ||
child.type === 'shorthand_property_identifier_pattern'
) {
if (keyNames.includes(child.text)) last = { kind: 'unknown' };
continue;
}
if (child.type !== 'pair') continue;
const keyNode = child.childForFieldName('key');
const valueNode = child.childForFieldName('value');
if (!keyNode) continue;
const key = propertyName(keyNode);
if (key === null || !keyNames.includes(key)) continue;
if (!valueNode || (valueNode.type !== 'string' && valueNode.type !== 'template_string')) {
last = { kind: 'unknown' };
continue;
}
const lit = unquoteLiteral(valueNode.text);
if (lit === null || lit.includes('${')) {
last = { kind: 'unknown' };
continue;
}
last = { kind: 'literal', value: lit };
}
if (last.kind === 'literal') return last.value.toUpperCase();
if (last.kind === 'unknown') return '*';
return 'GET';
}
function wrappedRequestReceiverName(receiver: string): string {
const parts = receiver.split('.');
return parts[parts.length - 1] ?? receiver;
}
/** Axios module / axios.create instance, or a registered wrapper identifier. */
function isAdmittedWrappedRequestReceiver(
receiver: string,
fileKey: string | undefined,
facts: JsRepoFacts | null,
): boolean {
if (WRAPPED_REQUEST_RECEIVERS.has(wrappedRequestReceiverName(receiver))) return true;
try {
const isModule =
facts === null || fileKey === undefined
? receiver === 'axios'
: isAxiosNamespace(fileKey, receiver, facts);
if (isModule) return true;
if (!facts || fileKey === undefined) return false;
return isHttpClientRef(fileKey, receiver, facts);
} catch {
return false;
}
}
/**
* Map each named import's LOCAL binding to its DECLARED export name and source
* module, by walking the file's `import { x as y } from 'm'` statements. Lets
@ -390,9 +500,10 @@ function resolveFactsFor(
*
* `/{param}` matches every one-segment provider route in the group, and
* `matching.exclude_links_param_only_paths` defaults to `false`. A path whose
* leading term is an unresolved placeholder is refused for the same reason
* nothing pins where it starts. (`resolveJsPathExpression` already refuses those
* it folded itself; this also covers the literal fallback below.)
* leading term is an unresolved placeholder is refused unless the next
* character is `/` that is the gateway-prefix shape
* `` `${serviceClient}/api/v1/x` `` that `stripLeadingTemplatePrefix` keeps.
* Bare `{param}` and `{param}api/x` stay rejected: nothing pins a route.
*/
function looksLikeHttpPath(path: string): boolean {
if (path === '') return false;
@ -404,7 +515,7 @@ function looksLikeHttpPath(path: string): boolean {
// unresolved term happened to contain a space.
const shape = path.replace(/\$\{[^}]+\}/g, '{param}');
if (/\s/.test(shape)) return false;
if (shape.startsWith('{param}')) return false;
if (shape.startsWith('{param}') && !shape.startsWith('{param}/')) return false;
// An all-digit string is a path only when it is written as one. A leading
// slash is that evidence: `client.get('/123')` is a route whose segment the
// consumer normalizer reads as `{param}`, while a bare `"5000"` folded out of
@ -672,8 +783,7 @@ function scanBundle(
if (!optionsNode) continue;
const path = readStringProp(optionsNode, ['url']);
if (path === null) continue;
const rawMethod = readStringProp(optionsNode, ['method']);
const method = (rawMethod ?? 'GET').toUpperCase();
const method = readRequestMethod(optionsNode, ['method']);
out.push({
role: 'consumer',
framework: 'axios',
@ -685,6 +795,37 @@ function scanBundle(
});
}
// Consumer: wrapped client `X.request({ url, method })` — the shared
// enterprise axios-instance shape (`httpClient.request` from
// win-request and friends). Emit the raw url (templates intact) so
// shared `normalizeConsumerPath` can strip a leading `${…}` gateway
// prefix and fold mid/tail interpolations to `{param}`. A plugin-side
// longest-slash-segment reducer would truncate those mid-templates
// before the shared normalizer ever saw them. This scan drops only
// static relative urls (no `${`, no leading `/`, not `https?://`).
// That filter is request-wrapper-specific: fetch/axios member forms
// already admit absolute urls and leave host stripping to
// `normalizeConsumerPath`.
for (const match of runCompiledPatterns(bundle.requestObject, tree)) {
const optionsNode = match.captures.options;
const objNode = match.captures.obj;
if (!optionsNode || !objNode) continue;
if (!isAdmittedWrappedRequestReceiver(objNode.text, fileKey, facts)) continue;
const rawUrl = readStringProp(optionsNode, ['url']);
if (rawUrl === null) continue;
const url = rawUrl.trim();
if (!url.includes('${') && !url.startsWith('/') && !/^https?:\/\//i.test(url)) continue;
out.push({
role: 'consumer',
framework: 'request',
method: readRequestMethod(optionsNode),
path: url,
name: null,
line: optionsNode.startPosition.row + 1,
confidence: 0.65,
});
}
for (const route of scanDataRouteTables(tree)) {
const imported =
route.handlerLocalName === undefined ? undefined : importMap.get(route.handlerLocalName);

View file

@ -292,13 +292,57 @@ export function normalizeHttpPath(p: string): string {
}
/**
* Consumer-side normalization is more aggressive:
* - template literals (`${x}`) `{param}`
* - strip protocol + host if the URL is absolute
* - numeric segments `{param}` (so `/api/orders/42` `/api/orders/{param}`)
* Strip LEADING template interpolations from a consumer url as gateway/host
* bindings the enterprise wrapper shape `` `${serviceClient}/api/v1/x` ``
* where `${serviceClient}` selects the gateway service, not a route segment.
* This is consumer-path framework semantics (mirrors how an absolute
* `https://host/path` url keeps only its path), so it lives here rather than
* in any one language plugin:
* - `` `${c}/api/x` `` `/api/x` (clean prefix; `${c}${d}/api/x` `/api/x`)
* - `` `${c}/api/x/${id}` `` `/api/x/${id}` (mid/tail interpolations are left for the `{param}` pass)
* Returns null when the stripped remainder is not a single-slash path:
* - remainder without `/` relative fragment (`${c}api/x`) or scheme/host
* (`${scheme}://${host}/api/x`); whether it is a path depends on
* unverifiable runtime state, so it is dropped rather than guessed at;
* - remainder starting with `//` protocol-relative (`${proto}//host/api/x`);
* keeping it would later collapse to `/host/api/x`.
*
* The `?` in a query string cannot leak into the brace matching: `${...}`
* spans are matched by braces here (before any `{param}` replacement), and
* `normalizeHttpPath` splits on `?` only after the whole `${...}` span
* including any `?` inside it has been collapsed to `{param}`. So
* `` `${c}/api/x?id=${id}` `` reduces to `/api/x` on both orderings.
*/
function normalizeConsumerPath(url: string): string {
const templated = url.replace(/\$\{[^}]+\}/g, '{param}').trim();
function stripLeadingTemplatePrefix(url: string): string | null {
if (!url.startsWith('${')) return url;
const rest = url.replace(/^(?:\$\{[^}]*\})+/, '');
return rest.startsWith('/') && !rest.startsWith('//') ? rest : null;
}
/**
* Placeholder substituted for `${...}` before WHATWG `URL` parsing so the
* parser cannot percent-encode our own `{param}` markers. A genuine encoded
* segment like `%7Bfoo%7D` then survives as a literal, instead of being
* rewritten into braces and folded into `{param}`.
*
* Private-use U+E000 cannot appear in a real URL path, so a literal
* `__gitnexus_http_param__` segment is not rewritten into `{param}`.
*/
const CONSUMER_PARAM_SENTINEL = '\uE000';
const CONSUMER_PARAM_SENTINEL_ENC = '%ee%80%80';
function restoreConsumerParamSentinel(pathOnly: string): string {
return pathOnly
.split(CONSUMER_PARAM_SENTINEL)
.join('{param}')
.replace(new RegExp(CONSUMER_PARAM_SENTINEL_ENC, 'gi'), '{param}');
}
/** Canonicalize a consumer URL after `stripLeadingTemplatePrefix`. */
function normalizeConsumerPath(url: string): string | null {
const stripped = stripLeadingTemplatePrefix(url.trim());
if (stripped === null) return null;
const templated = stripped.replace(/\$\{[^}]+\}/g, CONSUMER_PARAM_SENTINEL).trim();
let pathOnly = templated;
if (/^https?:\/\//i.test(templated)) {
try {
@ -307,6 +351,7 @@ function normalizeConsumerPath(url: string): string {
pathOnly = templated.replace(/^https?:\/\/[^/]+/i, '');
}
}
pathOnly = restoreConsumerParamSentinel(pathOnly);
const normalized = normalizeHttpPath(pathOnly || '/');
const segments = normalized
.split('/')
@ -999,6 +1044,12 @@ export class HttpRouteExtractor implements ContractExtractor {
for (const d of detections) {
if (d.role !== 'consumer') continue;
const pathNorm = normalizeConsumerPath(d.path);
// A consumer url that cannot be reduced to a routable path (e.g. a
// leading template binding that is neither a clean prefix nor a
// remainder that starts with `/`) is dropped here rather than emitted
// as a never-matching contract — same treatment the plugins give
// static relative urls at scan time.
if (pathNorm === null) continue;
// Resolve the function CONTAINING the fetch/axios call so the consumer
// contract carries a real symbolUid (was always '' — the gap that left
// cross-repo trace/impact unable to traverse HTTP links).

View file

@ -2764,6 +2764,12 @@ export function updateUser(id: string, data: unknown) {
export function listDefaults() {
return axios({ url: '/api/defaults' });
}
export function ignoreJqueryTypeKey() {
return axios({ url: '/api/typed', type: 'POST' });
}
export function quotedAxiosMethod() {
return axios({ url: '/api/quoted-ax', "method": 'DELETE' });
}
`,
);
@ -2773,6 +2779,111 @@ export function listDefaults() {
expect(consumers.find((c) => c.contractId === 'http::POST::/api/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::PUT::/api/users/{param}')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::POST::/api/typed')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/typed')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::DELETE::/api/quoted-ax')).toBeDefined();
});
it('extracts wrapped X.request({ url, method }) with shared prefix-strip and * verbs', async () => {
const dir = path.join(tmpDir, 'wrapped-request');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/client.ts'),
`
import axios from 'axios';
export function listOrders(httpClient, serviceClient, tenant, verb) {
return httpClient.request({ url: \`\${serviceClient}/api/v1/orders\`, method: 'post' });
}
export function getTenantOrder(httpClient, client, tenant) {
return httpClient.request({ url: \`\${client}/api/\${tenant}/orders\`, method: 'GET' });
}
export function rootPing(httpClient, client) {
return httpClient.request({ url: \`\${client}/\`, method: 'GET' });
}
export function dynamicVerb(httpClient) {
return httpClient.request({ url: '/api/orders', method: verb });
}
export function missingMethod(httpClient) {
return httpClient.request({ url: '/api/defaults' });
}
export function dropHostTemplate(httpClient, scheme, host) {
return httpClient.request({ url: \`\${scheme}://\${host}/api/x\`, method: 'GET' });
}
export function absTemplateParam(httpClient, id) {
return httpClient.request({ url: \`https://host/api/\${id}\`, method: 'GET' });
}
export function quotedMethod(httpClient) {
return httpClient.request({ url: '/api/quoted', "method": 'PATCH' });
}
export function spreadMethod(httpClient, config) {
return httpClient.request({ url: '/api/spread', ...config });
}
export function staticAbsolute(httpClient) {
return httpClient.request({ url: 'https://host/api/static', method: 'GET' });
}
export function protocolRelative(httpClient, proto) {
return httpClient.request({ url: \`\${proto}//host/api/proto\`, method: 'GET' });
}
export async function fetchGateway(gateway) {
return fetch(\`\${gateway}/api/users\`);
}
export function axiosGateway(gateway) {
return axios.get(\`\${gateway}/api/users\`);
}
export async function encodedBraceLiteral() {
return fetch('https://host/api/%7Bfoo%7D');
}
export async function literalSentinelSegment() {
return fetch('https://host/api/__gitnexus_http_param__');
}
`,
);
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/v1/orders')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/{param}/orders'),
).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::*::/api/orders')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/defaults')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/x')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/orders')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/{param}')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::PATCH::/api/quoted')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::*::/api/spread')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/static')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/proto')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/host/api/proto')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/users')).toBeDefined();
expect(consumers.find((c) => c.contractId === 'http::GET::/api/%7bfoo%7d')).toBeDefined();
expect(
consumers.find((c) => c.contractId === 'http::GET::/api/__gitnexus_http_param__'),
).toBeDefined();
});
it('does not mint HTTP consumers for ungated .request({ url }) helpers', async () => {
const dir = path.join(tmpDir, 'wrapped-request-negative');
fs.mkdirSync(path.join(dir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(dir, 'src/misc.ts'),
`
export function e2e(cy) {
return cy.request({ url: '/api/v1/orders', method: 'GET' });
}
export function enqueue(queue) {
return queue.request({ url: '/admin', method: 'DELETE' });
}
`,
);
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/v1/orders')).toBeUndefined();
expect(consumers.find((c) => c.contractId === 'http::DELETE::/admin')).toBeUndefined();
});
it('does not emit consumers for unrelated object-literal calls (negative control)', async () => {

View file

@ -843,3 +843,198 @@ describe('resolveJsImport', () => {
);
});
});
describe('wrapped X.request({ url, method }) detections', () => {
it('keeps the raw template so mid-path interpolations survive to the normalizer', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(
'httpClient.request({ url: `${client}/api/${tenant}/orders`, method: "GET" });',
),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.framework).toBe('request');
expect(detections[0]?.method).toBe('GET');
expect(detections[0]?.path).toBe('${client}/api/${tenant}/orders');
});
it('emits * when method is present but not a literal', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", method: verb });'),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.method).toBe('*');
});
it('uses the last duplicate method key, matching JS evaluation', () => {
const dynamicLast = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/orders', method: 'GET', method: verb });"),
),
);
expect(dynamicLast[0]?.method).toBe('*');
const literalLast = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/orders', method: verb, method: 'POST' });"),
),
);
expect(literalLast[0]?.method).toBe('POST');
});
it('defaults to GET only when method/type is absent', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(jsParser.parse('httpClient.request({ url: "/api/orders" });')),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.method).toBe('GET');
});
it('drops a static relative url at scan time', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: 'api/orders', method: 'GET' });"),
),
);
expect(detections).toHaveLength(0);
});
it('does not treat cy.request or queue.request as HTTP consumers', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(`
cy.request({ url: '/api/v1/orders', method: 'GET' });
queue.request({ url: '/admin', method: 'DELETE' });
`),
),
);
expect(detections).toHaveLength(0);
});
it('ignores a config object that is not the first request argument', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request('/actual', { url: '/metadata', method: 'GET' });"),
),
);
expect(detections).toHaveLength(0);
});
it('admits $http by spelling but not a bare api without axios proof', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse(`
$http.request({ url: '/api/orders', method: 'GET' });
api.request({ url: '/api/users', method: 'POST' });
`),
),
);
expect(detections).toEqual([
expect.objectContaining({ method: 'GET', path: '/api/orders', framework: 'request' }),
]);
});
it('reads quoted method/type keys and type: as the verb', () => {
const quoted = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", "method": "POST" });'),
),
);
expect(quoted[0]?.method).toBe('POST');
const typed = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/items', type: 'PUT' });"),
),
);
expect(typed[0]?.method).toBe('PUT');
});
it('emits * when method may arrive via object spread', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", ...config });'),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.method).toBe('*');
});
it('keeps static absolute wrapped-request urls for host stripping', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: 'https://host/api/x', method: 'GET' });"),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.path).toBe('https://host/api/x');
});
it('admits axios.create instances calling .request', () => {
const detections = consumers(
scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';
export const api = axios.create({ baseURL: '/' });
`,
'src/api/orders.ts': `
import { api } from '../lib/client';
export const create = () => api.request({ url: '/api/orders', method: 'POST' });
`,
},
'src/api/orders.ts',
),
);
expect(detections).toContainEqual(
expect.objectContaining({ role: 'consumer', method: 'POST', path: '/api/orders' }),
);
});
it('admits member-verb calls with a gateway-prefixed template', () => {
const detections = consumers(
scanRepo(
{
'src/lib/client.ts': `
import axios from 'axios';
export default axios.create({ baseURL: '/' });
`,
'src/api/users.ts': `
import api from '../lib/client';
export const list = (gateway: string) => api.get(\`\${gateway}/api/v1/users\`);
export const bare = (id: string) => api.get(\`\${id}\`);
export const glue = (c: string) => api.get(\`\${c}api/x\`);
`,
},
'src/api/users.ts',
),
);
expect(detections.map((d) => d.path)).toEqual(['${gateway}/api/v1/users']);
});
it('trims whitespace-prefixed absolute paths at scan time', () => {
const detections = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: ' /api/orders', method: 'GET' });"),
),
);
expect(detections).toHaveLength(1);
expect(detections[0]?.path).toBe('/api/orders');
});
it('emits * for interpolated method templates and shorthand method keys', () => {
const interpolated = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse('httpClient.request({ url: "/api/orders", method: `${verb}` });'),
),
);
expect(interpolated[0]?.method).toBe('*');
const shorthand = consumers(
JAVASCRIPT_HTTP_PLUGIN.scan(
jsParser.parse("httpClient.request({ url: '/api/orders', method });"),
),
);
expect(shorthand[0]?.method).toBe('*');
});
});