diff --git a/Makefile.cbm b/Makefile.cbm index 3ff50b81..4d81872a 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -326,7 +326,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 diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 15d691d3..c8d52d0c 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -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 : "{}"; diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 07f5ca7e..a3ce18e1 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -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)); } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 180ee85f..deec59fa 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -500,7 +500,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 = @@ -1194,12 +1196,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); @@ -1275,7 +1280,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); @@ -1368,7 +1375,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]; diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index b543aaaa..3a3ae0ca 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -37,6 +37,89 @@ enum { #include #include +/* 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 + * Flask / Rocket (incl. typed "") + * ${...} 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) { + 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) { @@ -96,7 +179,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); } @@ -351,7 +436,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]; @@ -1035,7 +1122,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); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 1eb10842..e25e8309 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -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}", "", + * "${...}") 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 diff --git a/tests/test_main.c b/tests/test_main.c index 042d613e..d8f87c37 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -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) */ diff --git a/tests/test_route_canon.c b/tests/test_route_canon.c new file mode 100644 index 00000000..206dd8fc --- /dev/null +++ b/tests/test_route_canon.c @@ -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}") 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 + +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/", 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); +}