mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-11 22:53:04 +00:00
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:
parent
3aa62be717
commit
b9613ee86b
4 changed files with 511 additions and 13 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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('*');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue