fix(routes): canonicalize path parameters so client calls match handlers

Route QNs (__route__METHOD__/path) are the rendezvous key for joining a client
HTTP_CALLS to the server handler that serves it, but they were built from the raw
path. Frameworks write path parameters with different placeholder syntaxes
(":id" Express/clients, "{id}" Axum/Spring/OpenAPI, "<id>" Flask/Rocket), so a
client call and its handler produced different QNs and never joined. Static
routes joined; parameterized routes (most of a REST surface) did not.

Add cbm_route_canon_path() which collapses each parameter token (":name",
"{name}", "<name>", "${...}") to a single "{}" token, and apply it to the path
at every HTTP-route QN construction site (pass_route_nodes, pass_parallel,
pass_calls, pass_cross_repo). Broker/async/infra QNs are left untouched. Only the
QN (identity/dedup key) is canonicalized; the Route node display name keeps the
original path. Parameter names are intentionally discarded so the same logical
endpoint matches across services that name the variable differently.

Adds tests/test_route_canon.c (11 cases). Fixes #505.

Signed-off-by: imateusdev <imateusdev@gmail.com>
This commit is contained in:
imateusdev authored and imateusdev committed 2026-06-23 13:52:08 -03:00
1 parent 684e35b0e2
commit 8b813675be
8 files changed
+244 -14

No files matched your search

+1 -1
View File
@@ -329,7 +329,7 @@ TEST_DISCOVER_SRCS = \
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c
TEST_WATCHER_SRCS = tests/test_watcher.c
+6 -2
View File
@@ -187,8 +187,9 @@ static void handle_route_registration(cbm_pipeline_ctx_t *ctx, const CBMCall *ca
const char **imp_keys, const char **imp_vals, int imp_count) {
const char *method = cbm_service_pattern_route_method(call->callee_name);
char route_qn[CBM_ROUTE_QN_SIZE];
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY",
call->first_string_arg);
cbm_route_canon_path(call->first_string_arg, cpath, sizeof(cpath)));
char route_props[CBM_SZ_256];
snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method ? method : "ANY");
int64_t route_id = cbm_gbuf_upsert_node(ctx->gbuf, "Route", call->first_string_arg, route_qn,
@@ -225,12 +226,15 @@ static int64_t create_svc_route_node(cbm_pipeline_ctx_t *ctx, const char *url, c
const char *method, const char *broker) {
char route_qn[CBM_ROUTE_QN_SIZE];
const char *prefix;
char cpath[CBM_SZ_256];
const char *qpath = url;
if (svc == CBM_SVC_HTTP) {
prefix = method ? method : "ANY";
qpath = cbm_route_canon_path(url, cpath, sizeof(cpath));
} else {
prefix = broker ? broker : "async";
}
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, url);
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, qpath);
const char *rp;
if (svc == CBM_SVC_HTTP) {
rp = method ? method : "{}";
+8 -4
View File
@@ -10,6 +10,7 @@
* get a CROSS_* edge so the link is visible from either side.
*/
#include "pipeline/pass_cross_repo.h"
#include "pipeline/pipeline_internal.h" // cbm_route_canon_path
#include "foundation/constants.h"
#include "foundation/log.h"
#include "foundation/platform.h"
@@ -283,10 +284,13 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project,
continue;
}
/* Build the expected Route QN in the target project */
/* Build the expected Route QN in the target project (param-canonicalized
* so client url_path matches the server handler regardless of framework
* placeholder syntax). */
char route_qn[CR_QN_BUF];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method[0] ? method : "ANY",
url_path);
char cpath[CBM_SZ_256];
const char *curl = cbm_route_canon_path(url_path, cpath, sizeof(cpath));
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method[0] ? method : "ANY", curl);
char handler_name[CBM_SZ_256] = {0};
char handler_file[CBM_SZ_512] = {0};
@@ -295,7 +299,7 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project,
handler_file, sizeof(handler_file));
if (handler_id == 0) {
/* Try without method (ANY) */
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", url_path);
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", curl);
handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name),
handler_file, sizeof(handler_file));
}
+13 -4
View File
@@ -502,7 +502,9 @@ static void insert_def_into_gbuf(extract_worker_state_t *ws, const cbm_file_info
if (def->route_path && def->route_path[0] != '\0') {
const char *rm = def->route_method ? def->route_method : "ANY";
char route_qn[CBM_ROUTE_QN_SIZE];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm, def->route_path);
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm,
cbm_route_canon_path(def->route_path, cpath, sizeof(cpath)));
char rprops[CBM_SZ_256];
snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\",\"source\":\"decorator\"}", rm);
int64_t route_id =
@@ -1210,12 +1212,15 @@ static int64_t build_service_route(cbm_gbuf_t *gbuf, const char *arg, const char
const char *broker, cbm_svc_kind_t svc) {
char route_qn[CBM_ROUTE_QN_SIZE];
const char *prefix;
char cpath[CBM_SZ_256];
const char *qpath = arg;
if (svc == CBM_SVC_HTTP) {
prefix = method ? method : "ANY";
qpath = cbm_route_canon_path(arg, cpath, sizeof(cpath));
} else {
prefix = broker ? broker : "async";
}
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, arg);
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, qpath);
char route_props[CBM_SZ_256];
if (method) {
snprintf(route_props, sizeof(route_props), "{\"method\":\"%s\"}", method);
@@ -1291,7 +1296,9 @@ static void emit_route_registration(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *sou
const char **ik, const char **iv, int ic) {
const char *method = cbm_service_pattern_route_method(call->callee_name);
char rqn[CBM_ROUTE_QN_SIZE];
snprintf(rqn, sizeof(rqn), "__route__%s__%s", method ? method : "ANY", route_path);
char cpath[CBM_SZ_256];
snprintf(rqn, sizeof(rqn), "__route__%s__%s", method ? method : "ANY",
cbm_route_canon_path(route_path, cpath, sizeof(cpath)));
char rp[CBM_SZ_256];
snprintf(rp, sizeof(rp), "{\"method\":\"%s\"}", method ? method : "ANY");
int64_t rid = cbm_gbuf_upsert_node(gbuf, "Route", route_path, rqn, "", 0, 0, rp);
@@ -1384,7 +1391,9 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source,
continue;
}
char route_qn[CBM_ROUTE_QN_SIZE];
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", norm);
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s",
cbm_route_canon_path(norm, cpath, sizeof(cpath)));
int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0,
"{\"source\":\"arg_url\"}");
char esc_c[CBM_SZ_256];
+98 -3
View File
@@ -39,6 +39,95 @@ enum {
bool cbm_service_pattern_is_http_route_literal(const char *literal, const char *callee_name);
/* True for characters that may appear in a ":name" route parameter. */
static inline bool is_route_ident_char(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
}
/* Canonicalize route-path parameter placeholders to a single "{}" token so that
* client call sites and server handlers rendezvous on the same Route QN
* regardless of framework syntax. Each parameter token collapses to "{}":
*
* :name Express / React-Router / Rails / typical JS API clients
* {name} Axum / Spring / OpenAPI / ASP.NET
* <name> Flask / Rocket (incl. typed "<int:id>")
* ${...} JS template interpolation captured into the path
*
* Parameter names are intentionally discarded so the same logical endpoint
* matches across services that name the path variable differently. Static path
* text is copied verbatim; the result never exceeds the input length. */
const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz) {
if (out == NULL || out_sz == 0) {
/* WHY: cppcheck value-flow follows a caller that passes an as-yet
* uninitialized output buffer (e.g. `char cpath[256];`) and flags this
* `return out` as uninitvar. False positive: we only return the pointer
* here and never read the buffer; returning it (NULL, or a buffer too
* small to write into) is the intended early-out contract. */
// cppcheck-suppress uninitvar
return out;
}
if (in == NULL) {
out[0] = '\0';
return out;
}
const size_t last = out_sz - 1;
size_t oi = 0;
size_t i = 0;
while (in[i] != '\0' && oi < last) {
char c = in[i];
bool at_seg_start = (oi == 0) || (out[oi - 1] == '/');
bool is_param = false;
if (c == ':' && at_seg_start && is_route_ident_char(in[i + 1])) {
i++;
while (in[i] != '\0' && is_route_ident_char(in[i])) {
i++;
}
is_param = true;
} else if (c == '{') {
i++;
while (in[i] != '\0' && in[i] != '}' && in[i] != '/') {
i++;
}
if (in[i] == '}') {
i++;
}
is_param = true;
} else if (c == '<') {
i++;
while (in[i] != '\0' && in[i] != '>' && in[i] != '/') {
i++;
}
if (in[i] == '>') {
i++;
}
is_param = true;
} else if (c == '$' && in[i + 1] == '{') {
i += 2;
while (in[i] != '\0' && in[i] != '}' && in[i] != '/') {
i++;
}
if (in[i] == '}') {
i++;
}
is_param = true;
}
if (is_param) {
if (oi + 2 > last) {
break;
}
out[oi++] = '{';
out[oi++] = '}';
continue;
}
out[oi++] = c;
i++;
}
out[oi] = '\0';
return out;
}
/* Extract a JSON string value by key from properties.
* Returns pointer into buf (caller provides buffer). NULL if not found. */
static const char *json_extract(const char *json, const char *key, char *buf, int bufsz) {
@@ -105,7 +194,9 @@ static void route_edge_visitor(const cbm_gbuf_edge_t *edge, void *userdata) {
/* Build Route QN */
char route_qn[CBM_ROUTE_QN_SIZE];
if (strcmp(edge->type, "HTTP_CALLS") == 0) {
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY", url);
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method ? method : "ANY",
cbm_route_canon_path(url, cpath, sizeof(cpath)));
} else {
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", broker ? broker : "async", url);
}
@@ -360,7 +451,9 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun
extract_json_prop(func->properties_json, "route_method", method, sizeof(method));
char route_qn[CBM_ROUTE_QN_SIZE];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method, path);
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method,
cbm_route_canon_path(path, cpath, sizeof(cpath)));
const cbm_gbuf_node_t *existing = cbm_gbuf_find_by_qn(gb, route_qn);
char rprops[CBM_SZ_256];
@@ -1056,7 +1149,9 @@ static void sveltekit_file_visitor(const cbm_gbuf_node_t *node, void *userdata)
}
char route_qn[CBM_ROUTE_QN_SIZE];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method, route_path);
char cpath[CBM_SZ_256];
snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", method,
cbm_route_canon_path(route_path, cpath, sizeof(cpath)));
char route_props[CBM_SZ_256];
snprintf(route_props, sizeof(route_props),
"{\"method\":\"%s\",\"framework\":\"sveltekit\"}", method);
+8
View File
@@ -25,6 +25,14 @@
/* Route node QN buffer size (must fit __route__METHOD__/full/url/path) */
#define CBM_ROUTE_QN_SIZE 768
/* Canonicalize route-path parameter placeholders (":id", "{id}", "<id>",
* "${...}") to a single "{}" token so that client call sites and server
* handlers rendezvous on the same Route QN regardless of framework syntax.
* Parameter names are intentionally discarded ("/u/{id}" and "/u/{slug}" both
* canonicalize to "/u/{}"). The result never exceeds the input length, so
* out_sz >= strlen(in) + 1 always suffices. Returns out. */
const char *cbm_route_canon_path(const char *in, char *out, size_t out_sz);
/* Time unit conversions */
#define CBM_NS_PER_SEC 1000000000LL
#define CBM_US_PER_SEC 1000000LL
+2
View File
@@ -39,6 +39,7 @@ extern void suite_graph_buffer(void);
extern void suite_registry(void);
extern void suite_pipeline(void);
extern void suite_fqn(void);
extern void suite_route_canon(void);
extern void suite_path_alias(void);
extern void suite_watcher(void);
extern void suite_lz4(void);
@@ -151,6 +152,7 @@ int main(void) {
RUN_SUITE(registry);
RUN_SUITE(pipeline);
RUN_SUITE(fqn);
RUN_SUITE(route_canon);
RUN_SUITE(path_alias);
/* Watcher (M10) */
+108
View File
@@ -0,0 +1,108 @@
/*
* test_route_canon.c — Unit tests for cbm_route_canon_path().
*
* Verifies that framework-specific route-parameter placeholder syntaxes
* (":id", "{id}", "<id>", "${id}") collapse to a single "{}" token so that a
* client call site and a server handler rendezvous on the same Route QN
* regardless of the language/framework that produced each side.
* See src/pipeline/pass_route_nodes.c.
*/
#include "test_framework.h"
#include "pipeline/pipeline_internal.h"
#include <string.h>
TEST(route_canon_static_unchanged) {
char b[128];
ASSERT_STR_EQ(cbm_route_canon_path("/products/categories", b, sizeof(b)),
"/products/categories");
PASS();
}
TEST(route_canon_colon_param) {
char b[128];
ASSERT_STR_EQ(cbm_route_canon_path("/players/:id", b, sizeof(b)), "/players/{}");
PASS();
}
TEST(route_canon_brace_param) {
char b[128];
ASSERT_STR_EQ(cbm_route_canon_path("/players/{id}", b, sizeof(b)), "/players/{}");
PASS();
}
/* The core invariant: Axum "{id}" and a JS client ":id" must converge. */
TEST(route_canon_colon_and_brace_converge) {
char a[128];
char c[128];
cbm_route_canon_path("/clients/{id}/authorized-users", a, sizeof(a));
cbm_route_canon_path("/clients/:clientId/authorized-users", c, sizeof(c));
ASSERT_STR_EQ(a, c);
ASSERT_STR_EQ(a, "/clients/{}/authorized-users");
PASS();
}
/* Parameter names are intentionally discarded ("{id}" == ":requestId"). */
TEST(route_canon_param_name_agnostic) {
char a[128];
char c[128];
cbm_route_canon_path("/link-requests/{id}/status", a, sizeof(a));
cbm_route_canon_path("/link-requests/:requestId/status", c, sizeof(c));
ASSERT_STR_EQ(a, c);
PASS();
}
TEST(route_canon_angle_param) {
char b[128];
ASSERT_STR_EQ(cbm_route_canon_path("/users/<int:id>", b, sizeof(b)), "/users/{}");
PASS();
}
TEST(route_canon_template_interpolation) {
char b[128];
ASSERT_STR_EQ(cbm_route_canon_path("/players/${playerId}", b, sizeof(b)), "/players/{}");
PASS();
}
TEST(route_canon_multiple_params) {
char b[128];
ASSERT_STR_EQ(cbm_route_canon_path("/orders/{id}/items/{itemIndex}", b, sizeof(b)),
"/orders/{}/items/{}");
PASS();
}
/* A ':' that is not at a segment start is literal, not a route parameter. */
TEST(route_canon_colon_mid_segment_is_literal) {
char b[128];
ASSERT_STR_EQ(cbm_route_canon_path("/a/b:c", b, sizeof(b)), "/a/b:c");
PASS();
}
TEST(route_canon_null_and_empty) {
char b[8];
ASSERT_STR_EQ(cbm_route_canon_path("", b, sizeof(b)), "");
ASSERT_STR_EQ(cbm_route_canon_path(NULL, b, sizeof(b)), "");
PASS();
}
/* A tight output buffer must still yield a bounded, NUL-terminated string. */
TEST(route_canon_truncation_safe) {
char b[6];
const char *r = cbm_route_canon_path("/players/:id", b, sizeof(b));
ASSERT(strlen(r) < sizeof(b));
PASS();
}
SUITE(route_canon) {
RUN_TEST(route_canon_static_unchanged);
RUN_TEST(route_canon_colon_param);
RUN_TEST(route_canon_brace_param);
RUN_TEST(route_canon_colon_and_brace_converge);
RUN_TEST(route_canon_param_name_agnostic);
RUN_TEST(route_canon_angle_param);
RUN_TEST(route_canon_template_interpolation);
RUN_TEST(route_canon_multiple_params);
RUN_TEST(route_canon_colon_mid_segment_is_literal);
RUN_TEST(route_canon_null_and_empty);
RUN_TEST(route_canon_truncation_safe);
}