fix(extract): read the url of a JS/TS request-config object (#2235)

A generated API client (Orval and similar) passes its request as a config
object to a local fetch wrapper:

    http<Customer>({url: `/api/customers/${id}`, method: 'GET', signal})

The template literal itself already canonicalizes to "/api/customers/{}"
(#1006). What was lost is the object wrapper: the call's arguments only
carried a value for bare strings, template literals, constants and URL
builders, so the object argument had no URL. The arg-url heuristic
therefore minted no HTTP_CALLS edge, and cross-repo matching had nothing to
join against the server's GET /api/customers/{id} Route. The same held for
HTTP clients called with a config object (`axios({url})`): no URL, no edge.

The extractor now reads the `url` property of an object argument in
JS/TS/TSX/ArkTS -- both as that argument's value (for the arg-url
heuristic) and as the call's URL (for HTTP-client classification). Only
the `url` key counts. Route registrations (Fastify `route({url, handler})`)
and axios `getUri(config)`, which formats a URL and sends nothing, are
excluded.

orval-labs/orval @ 08d7fcf1 (parallel resolver): HTTP_CALLS 1383 -> 1533
(+150 arg_url edges from the generated mutators such as customInstance,
responseType and customClient; 0 lost), Route 302 -> 308, CALLS unchanged
(19190). Without the getUri exclusion there would have been 546 URL-builder edges.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-09-25 22:26:38 +02:00
parent 64c23fab6c
commit 3ef7d8cdc2
3 changed files with 190 additions and 2 deletions
+66 -2
View File
@@ -2061,6 +2061,56 @@ static void process_keyword_arg(CBMExtractCtx *ctx, TSNode arg_node, CBMCallArg
}
}
static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node);
/* A JS/TS request-config object -- `http({url: `/users/${id}`, method: 'GET'})`,
* `axios({url: '/orders'})`, the shape Orval and similar generators emit -- names
* its request URL in the `url` property. Return that URL (template literals
* flattened to the canonical "{}" form, like a bare template argument), or NULL.
* Only the `url` key counts: `path`/`endpoint` in an options object name other
* things too often. Route registrations are excluded: an object there is the
* route definition (Fastify `route({url, handler})`), not a client request, and
* its handler lives inside the object where the route pass cannot read it. So is
* axios `getUri(config)`: it takes the same config but only formats the URL and
* sends nothing (Orval's `get<Op>Url` helpers). (#2235) */
static bool config_object_callee_eligible(const CBMExtractCtx *ctx, const char *callee_name) {
if (ctx->language != CBM_LANG_JAVASCRIPT && ctx->language != CBM_LANG_TYPESCRIPT &&
ctx->language != CBM_LANG_TSX && ctx->language != CBM_LANG_ARKTS) {
return false;
}
if (!callee_name || cbm_service_pattern_route_method(callee_name) != NULL) {
return false;
}
static const char get_uri[] = "getUri";
size_t clen = strlen(callee_name);
size_t glen = sizeof(get_uri) - 1;
return !(clen >= glen && strcmp(callee_name + clen - glen, get_uri) == 0);
}
static const char *js_config_object_url(CBMExtractCtx *ctx, TSNode obj, const char *callee_name) {
if (strcmp(ts_node_type(obj), "object") != 0 ||
!config_object_callee_eligible(ctx, callee_name)) {
return NULL;
}
uint32_t n = ts_node_named_child_count(obj);
for (uint32_t i = 0; i < n; i++) {
TSNode pair = ts_node_named_child(obj, i);
if (strcmp(ts_node_type(pair), "pair") != 0) {
continue;
}
TSNode key_n = ts_node_child_by_field_name(pair, TS_FIELD("key"));
TSNode val_n = ts_node_child_by_field_name(pair, TS_FIELD("value"));
if (ts_node_is_null(key_n) || ts_node_is_null(val_n)) {
continue;
}
const char *key = strip_quotes(ctx->arena, cbm_node_text(ctx->arena, key_n, ctx->source));
if (key && strcmp(key, "url") == 0) {
return extract_string_value(ctx, val_n);
}
}
return NULL;
}
/* Extract all arguments from a call expression into call->args[]. */
static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) {
uint32_t argc = ts_node_named_child_count(args);
@@ -2103,6 +2153,10 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) {
ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source);
} else if (strcmp(ak, "identifier") == 0 && ca->expr) {
ca->value = lookup_string_constant(ctx, ca->expr);
} else if (strcmp(ak, "object") == 0) {
/* Request-config object: its `url` is what the arg-url
* heuristic reads for a local fetch wrapper (#2235). */
ca->value = js_config_object_url(ctx, arg_node, call->callee_name);
} else if (strcmp(ak, "call_expression") == 0) {
/* URL-builder helper call (issue #1009): resolve
* client(buildPath(id)) through the per-file builder map. */
@@ -2367,7 +2421,8 @@ static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const
}
// Extract URL/topic from keyword or positional args.
static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args,
const char *callee_name) {
uint32_t nc = ts_node_named_child_count(args);
for (uint32_t ai = 0; ai < nc; ai++) {
TSNode arg = ts_node_named_child(args, ai);
@@ -2395,6 +2450,15 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
continue;
}
/* JS/TS request-config object: `axios({url: '/orders'})` (#2235). */
if (strcmp(ak, "object") == 0) {
const char *val = js_config_object_url(ctx, arg, callee_name);
if (val) {
return val;
}
continue;
}
/* Cloud SDK dispatch via input struct: the queue/topic target is a field
* of a composite literal (Go `&sqs.SendMessageInput{QueueUrl: ...}`), not
* a bare string arg. Recover it so the async edge forms. */
@@ -3832,7 +3896,7 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML
args = swift_call_args(node);
}
if (!ts_node_is_null(args)) {
call.first_string_arg = extract_url_or_topic_arg(ctx, args);
call.first_string_arg = extract_url_or_topic_arg(ctx, args, call.callee_name);
/* #952: routes registered inside Laravel `prefix()->group()`
* closures must carry the composed path — the resolve passes
* only see the flat CBMCall, so the enclosing chain can only
+57
View File
@@ -4867,6 +4867,62 @@ TEST(extract_ts_template_string_url_issue1006) {
PASS();
}
/* Issue #2235: a request-config object argument carries the URL in its `url`
* property (Orval/axios style: `http({url: `/x/${id}`, method: 'GET'})`).
* The property's template literal flattens to the canonical "{}" form both on
* the argument (read by the arg-url heuristic for local helpers) and as the
* call's URL (read by HTTP-client classification, e.g. `axios({url})`). Other
* keys never stand in for the URL, and a route registration keeps its own
* (object-free) handling. */
TEST(extract_ts_config_object_url_issue2235) {
CBMFileResult *r =
extract("export const get1 = (id: string) => {\n"
" return http<Customer>({url: `/api/customers/${id}`, method: 'GET'});\n"
"};\n"
"export const load = () => axios({method: 'POST', 'url': '/api/orders'});\n"
"export const other = () => http({path: '/api/ignored/x'});\n"
"export const reg = () => fastify.route({url: '/srv/x', handler: h});\n"
"export const u = () => axiosInstance.getUri({url: `/pets`});\n",
CBM_LANG_TYPESCRIPT, "t", "client.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
const CBMCall *c = NULL;
const CBMCall *o = NULL;
for (int i = 0; i < r->calls.count; i++) {
const CBMCall *k = &r->calls.items[i];
if (k->callee_name && strcmp(k->callee_name, "http") == 0 && k->arg_count > 0) {
if (k->args[0].expr && strstr(k->args[0].expr, "customers")) {
c = k;
} else {
o = k;
}
}
}
ASSERT_NOT_NULL(c);
ASSERT_NOT_NULL(c->args[0].value);
ASSERT_STR_EQ(c->args[0].value, "/api/customers/{}");
ASSERT_NOT_NULL(c->first_string_arg);
ASSERT_STR_EQ(c->first_string_arg, "/api/customers/{}");
const CBMCall *a = find_call_by_callee(r, "axios");
ASSERT_NOT_NULL(a);
ASSERT_NOT_NULL(a->first_string_arg);
ASSERT_STR_EQ(a->first_string_arg, "/api/orders");
ASSERT_NOT_NULL(o);
ASSERT_NULL(o->args[0].value);
ASSERT_NULL(o->first_string_arg);
const CBMCall *g = find_call_by_callee(r, "fastify.route");
ASSERT_NOT_NULL(g);
ASSERT_NULL(g->first_string_arg);
ASSERT(g->arg_count == 0 || g->args[0].value == NULL);
/* getUri only formats the URL; it sends no request. */
const CBMCall *gu = find_call_by_callee(r, "axiosInstance.getUri");
ASSERT_NOT_NULL(gu);
ASSERT_NULL(gu->first_string_arg);
ASSERT(gu->arg_count == 0 || gu->args[0].value == NULL);
cbm_free_result(r);
PASS();
}
/* Issue #1249: a mux route built as `configVar + "/literal"` (Go's idiomatic
* configurable-base-path pattern) must index the literal suffix, both for a
* route registration and for an outbound URL built the same way. A real BFF
@@ -8605,6 +8661,7 @@ SUITE(extraction) {
RUN_TEST(extract_razor_page_directive_routes_cshtml_view);
RUN_TEST(extract_razor_layout_without_page_has_no_route);
RUN_TEST(extract_ts_template_string_url_issue1006);
RUN_TEST(extract_ts_config_object_url_issue2235);
RUN_TEST(extract_go_binary_concat_url_issue1249);
RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249);
RUN_TEST(extract_ts_url_builder_issue1009);
+67
View File
@@ -6726,6 +6726,72 @@ TEST(pipeline_arg_url_rejects_non_http_slash_arguments) {
PASS();
}
/* #2235: an Orval-style generated client passes its URL inside a request
* config object to a LOCAL helper -- `http({url: `/api/customers/${id}`,
* method: 'GET'})`. The template literal itself already canonicalizes to
* "/api/customers/{}"; what was lost is the object wrapper: the arg-url
* heuristic only read bare string/template arguments, so the call produced
* no HTTP_CALLS edge and cross-repo matching had nothing to join. Parallel
* resolver (> 50 files), where the arg-url heuristic lives. A config object
* without a `url` key must not mint a route. */
TEST(pipeline_ts_config_object_url_http_calls_issue2235) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_i2235_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_temp_file(tmp, "src/http.ts",
"export const http = <T,>(config: { url: string; method: string }): "
"Promise<T> => {\n"
" return fetch(config.url, { method: config.method }).then((r) => r.json());\n"
"};\n");
write_temp_file(
tmp, "src/gen/customer-controller.ts",
"import { http } from '../http';\n"
"export const get1 = (id: string, signal?: AbortSignal) => {\n"
" return http<unknown>({url: `/api/customers/${id}`, method: 'GET', signal});\n"
"};\n"
"export const noUrl = () => {\n"
" return http<unknown>({path: '/api/ignored/x', method: 'GET'});\n"
"};\n");
for (int i = 0; i < 52; i++) {
char name[64];
char body[128];
snprintf(name, sizeof(name), "src/filler%d.ts", i);
snprintf(body, sizeof(body), "export function filler%d(): number { return %d; }\n", i, i);
write_temp_file(tmp, name, body);
}
char *old_workers = getenv("CBM_WORKERS");
char *saved = old_workers ? strdup(old_workers) : NULL;
cbm_setenv("CBM_WORKERS", "4", 1);
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/i2235.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
const char *project = cbm_pipeline_project_name(p);
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
/* RED before the fix: the url inside the config object was never read. */
ASSERT_EQ(named_edge_count(s, project, "HTTP_CALLS", "get1", "/api/customers/{}"), 1);
/* Only the `url` key names the request URL. */
ASSERT_EQ(count_nodes_named(s, project, "/api/ignored/x"), 0);
cbm_store_close(s);
cbm_pipeline_free(p);
if (saved) {
cbm_setenv("CBM_WORKERS", saved, 1);
free(saved);
} else {
cbm_unsetenv("CBM_WORKERS");
}
th_rmtree(tmp);
PASS();
}
/* Native `fetch()` (#856), sequential path (< 50 files → pass_calls.c). A bare
* unqualified call to the global fetch API has no import and no local
* definition anywhere in this project, so registry resolution comes back
@@ -15160,6 +15226,7 @@ SUITE(pipeline) {
RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);
RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier);
RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments);
RUN_TEST(pipeline_ts_config_object_url_http_calls_issue2235);
RUN_TEST(pipeline_native_fetch_classified_as_http_calls);
RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892);
RUN_TEST(pipeline_swift_http_call_makes_route_issue1892);