Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile.cbm
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions src/pipeline/pass_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 : "{}";
Expand Down
12 changes: 8 additions & 4 deletions src/pipeline/pass_cross_repo.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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};
Expand All @@ -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));
}
Expand Down
17 changes: 13 additions & 4 deletions src/pipeline/pass_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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];
Expand Down
95 changes: 92 additions & 3 deletions src/pipeline/pass_route_nodes.c
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,89 @@ enum {
#include <stdio.h>
#include <string.h>

/* 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) {
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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions src/pipeline/pipeline_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/test_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) */
Expand Down
108 changes: 108 additions & 0 deletions tests/test_route_canon.c
Original file line number Diff line number Diff line change
@@ -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);
}
Loading