Nilorea Library
C utilities for networking, threading, graphics
Loading...
Searching...
No Matches
ex_http_router.c

n_http_router: a blog's route table, typed placeholders checked on the raw path, specificity instead of table order, 404 / 405 / 400, HEAD on GET routes, ambiguity and pattern checks

n_http_router: a blog's route table, typed placeholders checked on the raw path, specificity instead of table order, 404 / 405 / 400, HEAD on GET routes, ambiguity and pattern checks

Author
Castagnier Mickael
Version
1.0
Date
23/10/2026
/*
* Nilorea Library
* Copyright (C) 2005-2026 Castagnier Mickael
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
* implied. See the License for the specific language governing
* permissions and limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "nilorea/n_log.h"
static int log_level = LOG_ERR;
static int failures = 0;
static void expect_true(const char* what, int ok) {
if (!ok) {
n_log(LOG_ERR, "FAIL: %s", what);
} else {
n_log(LOG_INFO, "ok: %s", what);
}
}
static int process_args(int argc, char** argv) {
int opt = 0;
while ((opt = getopt(argc, argv, "V:")) != -1) {
if (opt == 'V') {
log_level = atoi(optarg);
} else {
return -1;
}
}
return 0;
}
static int h(void* ctx, const N_ROUTE_MATCH* m) {
(void)ctx;
(void)m;
return 0;
}
static const N_ROUTE PUBLIC[] = {
{N_ROUTE_GET, "/{slug:slug}/", h, "page"},
{N_ROUTE_GET, "/", h, "home"},
{N_ROUTE_GET, "/page/{n:int6}/", h, "home-paged"},
{N_ROUTE_GET, "/category/{slug:slug}/", h, "category"},
{N_ROUTE_GET, "/category/{slug:slug}/page/{n:int6}/", h, "category-paged"},
{N_ROUTE_GET, "/{y:d4}/{m:d2}/", h, "month"},
{N_ROUTE_GET, "/{y:d4}/{m:d2}/{d:d2}/{slug:slug}/", h, "post"},
{N_ROUTE_GET, "/feed/", h, "feed"},
{N_ROUTE_GET, "/sitemap.xml", h, "sitemap"},
{N_ROUTE_GET, "/media/{id:hex16}/{name:seg}", h, "media"},
{N_ROUTE_GET, "/media/{id:hex16}/{w:int4}/{name:seg}", h, "variant"},
{N_ROUTE_GET, "/wp-content/uploads/{path:rest}", h, "legacy-upload"},
};
static const N_ROUTE ADMIN[] = {
{N_ROUTE_GET, "/admin/login", h, "login-form"},
{N_ROUTE_POST, "/admin/login", h, "login"},
{N_ROUTE_GET | N_ROUTE_POST, "/admin/posts/{id:int9}", h, "post-edit"},
{N_ROUTE_POST, "/admin/posts/{id:int9}/trash", h, "post-trash"},
};
static void expect_route(const N_ROUTER* r, n_http_req_method method, const char* path, const char* want, N_ROUTE_MATCH* m) {
N_ROUTER_RESULT res = n_router_match(r, method, path, strlen(path), m, NULL);
int ok = want ? (res == N_ROUTER_FOUND && strcmp((const char*)m->route->data, want) == 0) : res == N_ROUTER_NOT_FOUND;
if (!ok) {
n_log(LOG_ERR, "FAIL: %s -> result %d, route %s, wanted %s", path, (int)res,
res == N_ROUTER_FOUND ? (const char*)m->route->data : "-", want ? want : "not found");
}
}
static int param_is(const N_ROUTE_MATCH* m, const char* name, const char* want) {
size_t len = 0;
const char* v = n_route_param(m, name, &len);
return v && len == strlen(want) && strcmp(v, want) == 0;
}
static void test_match(const N_ROUTER* r) {
static N_ROUTE_MATCH m;
unsigned allowed = 0;
char allow[32];
expect_route(r, N_HTTP_REQ_GET, "/", "home", &m);
expect_route(r, N_HTTP_REQ_GET, "/about/", "page", &m);
expect_true("slug captured", param_is(&m, "slug", "about"));
expect_route(r, N_HTTP_REQ_GET, "/feed/", "feed", &m);
expect_route(r, N_HTTP_REQ_GET, "/page/3/", "home-paged", &m);
expect_true("int captured", param_is(&m, "n", "3"));
expect_route(r, N_HTTP_REQ_GET, "/page/1234567/", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/category/c_code/", "category", &m);
expect_route(r, N_HTTP_REQ_GET, "/category/c_code/page/2/", "category-paged", &m);
expect_route(r, N_HTTP_REQ_GET, "/2026/10/", "month", &m);
expect_route(r, N_HTTP_REQ_GET, "/2026/10/23/clock_gettime/", "post", &m);
expect_true("post placeholders", param_is(&m, "y", "2026") && param_is(&m, "m", "10") && param_is(&m, "d", "23") &&
param_is(&m, "slug", "clock_gettime") && m.nb_params == 4);
expect_route(r, N_HTTP_REQ_GET, "/2026/1/23/x/", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/2026/10/23/x", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/feed", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/sitemap.xml", "sitemap", &m);
expect_route(r, N_HTTP_REQ_GET, "/sitemap.xml/", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/About/", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/ab%63/", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/media/0123456789abcdef/photo%20one.jpg", "media", &m);
expect_true("seg decoded", param_is(&m, "name", "photo one.jpg"));
expect_route(r, N_HTTP_REQ_GET, "/media/0123456789abcdef/960/photo.webp", "variant", &m);
expect_route(r, N_HTTP_REQ_GET, "/media/0123456789ABCDEF/photo.jpg", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/media/0123456789abcdef/a%2Fb", "media", &m);
expect_true("%2F decodes inside a seg value", param_is(&m, "name", "a/b"));
expect_route(r, N_HTTP_REQ_GET, "/wp-content/uploads/2019/05/img-300x200.jpg", "legacy-upload", &m);
expect_true("rest takes the remainder", param_is(&m, "path", "2019/05/img-300x200.jpg"));
expect_route(r, N_HTTP_REQ_GET, "/wp-content/uploads/", NULL, &m);
expect_route(r, N_HTTP_REQ_GET, "/wp-content/uploads/caf%C3%A9.png", "legacy-upload", &m);
expect_true("rest decoded", param_is(&m, "path", "caf\xc3\xa9.png"));
expect_route(r, N_HTTP_REQ_GET, "//", NULL, &m);
expect_route(r, N_HTTP_REQ_HEAD, "/feed/", "feed", &m);
expect_route(r, N_HTTP_REQ_GET, "/admin/login", "login-form", &m);
expect_route(r, N_HTTP_REQ_POST, "/admin/login", "login", &m);
expect_route(r, N_HTTP_REQ_POST, "/admin/posts/12", "post-edit", &m);
expect_route(r, N_HTTP_REQ_POST, "/admin/posts/12/trash", "post-trash", &m);
expect_true("POST on a GET route: 405",
n_router_match(r, N_HTTP_REQ_POST, "/feed/", 6, &m, &allowed) == N_ROUTER_METHOD_NOT_ALLOWED && allowed == (N_ROUTE_GET | N_ROUTE_HEAD));
expect_true("Allow header", n_router_allow_header(allowed, allow, sizeof(allow)) && strcmp(allow, "GET, HEAD") == 0);
expect_true("GET on a POST-only route: 405 POST",
n_router_match(r, N_HTTP_REQ_GET, "/admin/posts/1/trash", 20, &m, &allowed) == N_ROUTER_METHOD_NOT_ALLOWED &&
n_router_allow_header(allowed, allow, sizeof(allow)) && strcmp(allow, "POST") == 0);
expect_true("Allow for both", n_router_allow_header(N_ROUTE_GET | N_ROUTE_POST, allow, sizeof(allow)) && strcmp(allow, "GET, HEAD, POST") == 0);
expect_true("Allow buffer too small", !n_router_allow_header(N_ROUTE_GET, allow, 4));
expect_true("malformed escape: 400", n_router_match(r, N_HTTP_REQ_GET, "/a%zz/", 7, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("escape cut short: 400", n_router_match(r, N_HTTP_REQ_GET, "/a%4", 4, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("decoded NUL: 400", n_router_match(r, N_HTTP_REQ_GET, "/a%00b/", 7, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("ill-formed UTF-8 anywhere: 400", n_router_match(r, N_HTTP_REQ_GET, "/x/%E9/", 7, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("UTF-8 split across segments: 400", n_router_match(r, N_HTTP_REQ_GET, "/%C3/%A9", 8, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("no leading '/': 400", n_router_match(r, N_HTTP_REQ_GET, "feed/", 5, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("empty path: 400", n_router_match(r, N_HTTP_REQ_GET, "", 0, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("NULL router: 400", n_router_match(NULL, N_HTTP_REQ_GET, "/", 1, &m, NULL) == N_ROUTER_BAD_REQUEST);
expect_true("absent param", n_route_param(&m, "nope", NULL) == NULL);
{
char deep[200];
size_t i, o = 0;
for (i = 0; i < 40; i++) {
deep[o++] = '/';
deep[o++] = 'a';
}
expect_true("too many segments: 404", n_router_match(r, N_HTTP_REQ_GET, deep, o, &m, NULL) == N_ROUTER_NOT_FOUND);
}
}
static void test_checks(void) {
char err[256];
static const N_ROUTE dup[] = {{N_ROUTE_GET, "/a/{x:slug}/", h, NULL}, {N_ROUTE_GET, "/a/{y:slug}/", h, NULL}};
static const N_ROUTE dhex[] = {{N_ROUTE_GET, "/b/{x:d4}", h, NULL}, {N_ROUTE_GET, "/b/{y:hex4}", h, NULL}};
static const N_ROUTE dlen[] = {{N_ROUTE_GET, "/c/{x:d4}", h, NULL}, {N_ROUTE_GET, "/c/{y:hex16}", h, NULL}};
static const N_ROUTE methods[] = {{N_ROUTE_GET, "/d/{x:slug}", h, NULL}, {N_ROUTE_POST, "/d/{y:slug}", h, NULL}};
static const N_ROUTE headget[] = {{N_ROUTE_GET, "/e", h, NULL}, {N_ROUTE_HEAD, "/e", h, NULL}};
n_router_add(r, dup, 2);
expect_true("same shape twice is ambiguous", n_router_check(r, err, sizeof(err)) == FALSE && strstr(err, "/a/{x:slug}/") != NULL);
r = n_router_new();
n_router_add(r, dhex, 2);
expect_true("d4 against hex4 is ambiguous", n_router_check(r, err, sizeof(err)) == FALSE);
r = n_router_new();
n_router_add(r, dlen, 2);
expect_true("d4 against hex16 is not", n_router_check(r, err, sizeof(err)) == TRUE && err[0] == '\0');
r = n_router_new();
n_router_add(r, methods, 2);
expect_true("same shape, other method, is not", n_router_check(r, err, sizeof(err)) == TRUE);
r = n_router_new();
n_router_add(r, headget, 2);
expect_true("HEAD route beside a GET one is ambiguous", n_router_check(r, err, sizeof(err)) == FALSE);
expect_true("free sets NULL", r == NULL);
{
static const char* bad[] = {
"noslash", "/a//b", "/{x", "/{x}y", "/{}", "/{X:seg}", "/{x:what}", "/{x:d0}", "/{x:d19}", "/{x:hex65}",
"/{x:int}", "/{x:seg}/{x:seg}", "/{r:rest}/more", "/{r:rest}/", "/a%20b", "/a b", "/a?b",
"/{a}/{b}/{c}/{d}/{e}/{f}/{g}/{h}/{i}", "/{abcdefghijklmnopqrstuvwxyz012345}"};
size_t i;
for (i = 0; i < sizeof(bad) / sizeof(bad[0]); i++) {
N_ROUTE one = {N_ROUTE_GET, bad[i], h, NULL};
r = n_router_new();
if (n_router_add(r, &one, 1) != FALSE || n_router_count(r) != 0) {
n_log(LOG_ERR, "FAIL: pattern '%s' accepted", bad[i]);
}
}
}
{
N_ROUTE nomethod = {0, "/x", h, NULL};
N_ROUTE nohandler = {N_ROUTE_GET, "/x", NULL, NULL};
N_ROUTE badbit = {1u << 9, "/x", h, NULL};
N_ROUTE mixed[] = {{N_ROUTE_GET, "/ok", h, NULL}, {N_ROUTE_GET, "/bad//", h, NULL}};
r = n_router_new();
expect_true("route without method refused", n_router_add(r, &nomethod, 1) == FALSE);
expect_true("route without handler refused", n_router_add(r, &nohandler, 1) == FALSE);
expect_true("unknown method bit refused", n_router_add(r, &badbit, 1) == FALSE);
expect_true("one bad route: nothing of the table added", n_router_add(r, mixed, 2) == FALSE && n_router_count(r) == 0);
expect_true("empty table is fine", n_router_add(r, NULL, 0) == TRUE);
}
}
int main(int argc, char** argv) {
N_ROUTER* r = NULL;
char err[256];
if (process_args(argc, argv) != 0) {
fprintf(stderr, "usage: %s [-V LOG_LEVEL]\n", argv[0]);
return 1;
}
r = n_router_new();
expect_true("public table added", n_router_add(r, PUBLIC, sizeof(PUBLIC) / sizeof(PUBLIC[0])));
expect_true("admin table added", n_router_add(r, ADMIN, sizeof(ADMIN) / sizeof(ADMIN[0])));
expect_true("route count", n_router_count(r) == 16);
expect_true("route access", n_router_route(r, 1) == &PUBLIC[1] && n_router_route(r, 12) == &ADMIN[0] && n_router_route(r, 16) == NULL);
expect_true("the blog's tables are not ambiguous", n_router_check(r, err, sizeof(err)) == TRUE);
if (err[0])
n_log(LOG_ERR, "%s", err);
if (failures) {
n_log(LOG_ERR, "ex_http_router: %d failure(s)", failures);
return 1;
}
n_log(LOG_NOTICE, "ex_http_router: all checks passed");
return 0;
}
void process_args(int argc, char **argv)
static int failures
Definition ex_atlas.c:51
int main(void)
Definition ex_atlas.c:117
static void expect_true(const char *label, int cond)
Definition ex_atlas.c:53
static int log_level
static void test_checks(void)
the start-up checks
static int param_is(const N_ROUTE_MATCH *m, const char *name, const char *want)
param name of the last match equals want
static void test_match(const N_ROUTER *r)
matching on the blog's tables
static int h(void *ctx, const N_ROUTE_MATCH *m)
a handler that does nothing (the test only routes)
static const N_ROUTE PUBLIC[]
the public routes of the Nilorea blog, in a deliberately unhelpful order: the catch-all "/{slug}...
static const N_ROUTE ADMIN[]
a few admin routes
static void expect_route(const N_ROUTER *r, n_http_req_method method, const char *path, const char *want, N_ROUTE_MATCH *m)
route path with method and expect a route data tag (NULL: not found)
#define n_log(__LEVEL__,...)
Logging function wrapper to get line and func.
Definition n_log.h:113
#define LOG_ERR
error conditions
Definition n_log.h:100
void set_log_level(const int log_level)
Set the global log level value ( static int LOG_LEVEL ).
Definition n_log.c:123
#define LOG_NOTICE
normal but significant condition
Definition n_log.h:104
#define LOG_INFO
informational
Definition n_log.h:106
n_http_req_method
The methods accepted.
Definition n_http_req.h:77
@ N_HTTP_REQ_POST
Definition n_http_req.h:79
@ N_HTTP_REQ_GET
Definition n_http_req.h:78
@ N_HTTP_REQ_HEAD
Definition n_http_req.h:80
size_t nb_params
number of placeholders
const N_ROUTE * route
the route that matched
const void * data
caller data for this route (a policy: auth, CSRF, limits...)
#define N_ROUTE_POST
route accepts POST
N_ROUTER_RESULT
result of n_router_match()
N_ROUTER * n_router_new(void)
new, empty router
int n_router_allow_header(unsigned allowed, char *out, size_t size)
write an Allow header value for a method mask (GET implies HEAD)
N_ROUTER_RESULT n_router_match(const N_ROUTER *r, n_http_req_method method, const char *path, size_t len, N_ROUTE_MATCH *match, unsigned *allowed)
find the route for a method and a path
#define N_ROUTE_HEAD
route accepts HEAD only (rare: GET routes answer HEAD already)
#define N_ROUTE_GET
route accepts GET (and so HEAD)
int n_router_check(const N_ROUTER *r, char *err, size_t errsz)
refuse two routes sharing a method that can match the same path with the same specificity
void n_router_free(N_ROUTER **r)
free a router and set *r to NULL (the route tables are not touched)
size_t n_router_count(const N_ROUTER *r)
number of routes added
const N_ROUTE * n_router_route(const N_ROUTER *r, size_t i)
the i-th route added
int n_router_add(N_ROUTER *r, const N_ROUTE *routes, size_t count)
compile and add a route table; nothing is added when a route is refused
const char * n_route_param(const N_ROUTE_MATCH *m, const char *name, size_t *len)
decoded value of a placeholder
@ N_ROUTER_BAD_REQUEST
the path itself is invalid: 400
@ N_ROUTER_FOUND
a route matched path and method
@ N_ROUTER_NOT_FOUND
no route matches the path: 404 or a fallback
@ N_ROUTER_METHOD_NOT_ALLOWED
the path matches, the method does not: 405
one route of a table
a successful match: the route and its decoded placeholder values
Common headers and low-level functions & define.
a compiled router (opaque)
HTTP router: static route tables, typed placeholders, specificity.
Generic log system.