Eio HTTP server with static file serving and route handlers
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

prune cram fixtures: declare fmt dep missed in Printf→Fmt migration

Commit 5fbed21c switched every cram test fixture from Printf to Fmt
without updating the dune stanzas to depend on fmt, so `dune build`
inside the fixtures fails and the cram expected output stopped
matching reality. Add fmt to each executable/library and refresh the
one stale expected block (cascade_cleanup) still showing Printf.

+413 -31
+84 -10
lib/respond.ml
··· 71 71 (* ── Routes ────────────────────────────────────────────────────────── *) 72 72 73 73 type params = (string * string) list 74 - type post_request = { params : params; body : string; headers : Headers.t } 75 - type get_request = { params : params; headers : Headers.t } 74 + 75 + type post_request = { 76 + path : string; 77 + path_params : params; 78 + params : params; 79 + body : string; 80 + headers : Headers.t; 81 + } 82 + 83 + type get_request = { 84 + path : string; 85 + path_params : params; 86 + params : params; 87 + headers : Headers.t; 88 + } 76 89 77 90 type handler = 78 91 | Get of (get_request -> Response.t) 79 92 | Post of (post_request -> Response.t) 80 93 81 - type route = { meth : [ `GET | `POST ]; path : string; handler : handler } 94 + (* Route patterns are split into segments. 95 + [Lit s] matches the literal segment [s]. 96 + [Param name] matches any single segment and binds it to [name]. 97 + [Rest] matches zero or more trailing segments, bound to key ["rest"]. 98 + ["**"] must be the last segment of a pattern. *) 99 + type segment = Lit of string | Param of string | Rest 100 + type pattern = segment list 101 + 102 + type route = { 103 + meth : [ `GET | `POST ]; 104 + pattern : pattern; 105 + raw : string; 106 + handler : handler; 107 + } 108 + 109 + let compile_pattern raw = 110 + let segs = String.split_on_char '/' raw |> List.filter (fun s -> s <> "") in 111 + let rec build = function 112 + | [] -> [] 113 + | [ "**" ] -> [ Rest ] 114 + | "**" :: _ -> 115 + invalid_arg 116 + (Fmt.str 117 + "Respond: '**' must be the last segment of a route pattern (%s)" 118 + raw) 119 + | seg :: rest -> 120 + let s = 121 + if String.length seg > 1 && seg.[0] = ':' then 122 + Param (String.sub seg 1 (String.length seg - 1)) 123 + else Lit seg 124 + in 125 + s :: build rest 126 + in 127 + build segs 82 128 83 - let get path f = { meth = `GET; path; handler = Get f } 84 - let post path f = { meth = `POST; path; handler = Post f } 129 + let get path f = 130 + { meth = `GET; pattern = compile_pattern path; raw = path; handler = Get f } 131 + 132 + let post path f = 133 + { meth = `POST; pattern = compile_pattern path; raw = path; handler = Post f } 85 134 86 135 (* ── HTTP helpers ──────────────────────────────────────────────────── *) 87 136 ··· 242 291 243 292 (* ── Request handling ─────────────────────────────────────────────── *) 244 293 245 - let match_route routes path = List.find_opt (fun r -> r.path = path) routes 294 + let match_pattern pattern path = 295 + let segs = 296 + String.split_on_char '/' path 297 + |> List.filter (fun s -> s <> "") 298 + |> List.map pct_decode 299 + in 300 + let rec loop pat segs acc = 301 + match (pat, segs) with 302 + | [], [] -> Some (List.rev acc) 303 + | [ Rest ], rest -> 304 + Some (List.rev (("rest", String.concat "/" rest) :: acc)) 305 + | Lit s :: pt, seg :: st when s = seg -> loop pt st acc 306 + | Param name :: pt, seg :: st -> loop pt st ((name, seg) :: acc) 307 + | _ -> None 308 + in 309 + loop pattern segs [] 310 + 311 + let match_route routes path = 312 + let rec loop = function 313 + | [] -> None 314 + | r :: rest -> ( 315 + match match_pattern r.pattern path with 316 + | Some bindings -> Some (r, bindings) 317 + | None -> loop rest) 318 + in 319 + loop routes 246 320 247 321 let read_body reader headers = 248 322 match Headers.find `Content_length headers with ··· 269 343 let path, params = parse_url url in 270 344 let body = read_body reader headers in 271 345 match match_route routes path with 272 - | Some { handler = Post handler; _ } -> ( 273 - let req = { params; body; headers } in 346 + | Some ({ handler = Post handler; _ }, path_params) -> ( 347 + let req = { path; path_params; params; body; headers } in 274 348 try 275 349 let r = handler req in 276 350 Log.info (fun m -> ··· 288 362 let is_head = meth_str = "HEAD" in 289 363 let path, params = parse_url url in 290 364 match match_route routes path with 291 - | Some { handler = Get handler; _ } -> ( 365 + | Some ({ handler = Get handler; _ }, path_params) -> ( 292 366 try 293 - let r = handler { params; headers } in 367 + let r = handler { path; path_params; params; headers } in 294 368 Log.info (fun m -> 295 369 m "%s %s %s" meth_str path (status_line r.Response.status)); 296 370 send_response ~head:is_head flow r
+36 -11
lib/respond.mli
··· 68 68 (** [redirect url] returns a 302 Found response with a [Location] header. *) 69 69 end 70 70 71 - (** {1 Routes} *) 71 + (** {1 Routes} 72 72 73 - type get_request = { params : params; headers : Headers.t } 74 - (** GET request with query parameters and parsed headers. *) 73 + Route patterns use Dream-style syntax: 74 + - [":name"] matches a single URL segment and binds it to [name] 75 + - ["**"] matches zero or more trailing segments, bound to key ["rest"] 76 + - everything else is a literal segment 77 + 78 + Examples: [/auth/:slug/callback], [/blocks/:hash], [/:branch/**]. The first 79 + matching route wins (registration order), so register more specific routes 80 + before catch-alls. *) 75 81 76 - type post_request = { params : params; body : string; headers : Headers.t } 77 - (** POST request with raw body and parsed headers. *) 82 + type get_request = { 83 + path : string; 84 + path_params : params; 85 + params : params; 86 + headers : Headers.t; 87 + } 88 + (** GET request. [path] is the raw URL path; [path_params] holds captures from 89 + [:name] segments and the ["rest"] capture from [**]; [params] is the query 90 + string. *) 91 + 92 + type post_request = { 93 + path : string; 94 + path_params : params; 95 + params : params; 96 + body : string; 97 + headers : Headers.t; 98 + } 99 + (** POST request with raw body, path captures, and query parameters. *) 78 100 79 101 type route 80 - (** A typed route: method + path + handler. *) 102 + (** A typed route: method + compiled pattern + handler. *) 81 103 82 104 val get : string -> (get_request -> Response.t) -> route 83 - (** [get path handler] handles GET requests at [path]. *) 105 + (** [get pattern handler] handles GET requests whose path matches [pattern]. 106 + Raises [Invalid_argument] if [**] appears before the last segment. *) 84 107 85 108 val post : string -> (post_request -> Response.t) -> route 86 - (** [post path handler] handles POST requests at [path]. *) 109 + (** [post pattern handler] handles POST requests whose path matches [pattern]. 110 + *) 87 111 88 112 (** {1 Utilities} *) 89 113 ··· 103 127 (** [generate_etag ~size] produces a weak ETag string derived from the content 104 128 size. *) 105 129 106 - val match_route : route list -> string -> route option 107 - (** [match_route routes path] returns the first route whose path matches [path], 108 - or [None]. *) 130 + val match_route : route list -> string -> (route * params) option 131 + (** [match_route routes path] returns the first route whose pattern matches 132 + [path], together with the captured [path_params]. Returns [None] if no route 133 + matches. *) 109 134 110 135 (** {1 Running} *) 111 136
+293 -10
test/test_respond.ml
··· 303 303 304 304 (* ── Routing ───────────────────────────────────────────────────────── *) 305 305 306 + let ok _ = Respond.Response.json "ok" 307 + 308 + let bindings_of path routes = 309 + match Respond.match_route routes path with 310 + | None -> None 311 + | Some (_, b) -> Some b 312 + 306 313 let test_route_exact () = 307 - let routes = 308 - [ 309 - Respond.get "/api/health" (fun _ -> Respond.Response.json "ok"); 310 - Respond.get "/api/cdms" (fun _ -> Respond.Response.json "[]"); 311 - ] 312 - in 314 + let routes = [ Respond.get "/api/health" ok; Respond.get "/api/cdms" ok ] in 313 315 check_bool "found" true (Respond.match_route routes "/api/health" <> None); 314 316 check_bool "found 2" true (Respond.match_route routes "/api/cdms" <> None) 315 317 316 318 let test_route_no_match () = 317 - let routes = 318 - [ Respond.get "/api/health" (fun _ -> Respond.Response.json "ok") ] 319 - in 319 + let routes = [ Respond.get "/api/health" ok ] in 320 320 check_bool "not found" true (Respond.match_route routes "/api/unknown" = None) 321 321 322 322 let test_route_no_prefix () = 323 - let routes = [ Respond.get "/api" (fun _ -> Respond.Response.json "ok") ] in 323 + let routes = [ Respond.get "/api" ok ] in 324 324 check_bool "no prefix" true (Respond.match_route routes "/api/health" = None) 325 325 326 + let test_route_param () = 327 + let routes = [ Respond.get "/auth/:slug/callback" ok ] in 328 + match bindings_of "/auth/github/callback" routes with 329 + | Some [ ("slug", "github") ] -> () 330 + | _ -> Alcotest.fail "expected slug=github" 331 + 332 + let test_route_multiple_params () = 333 + let routes = [ Respond.get "/users/:uid/posts/:pid" ok ] in 334 + match bindings_of "/users/42/posts/99" routes with 335 + | Some [ ("uid", "42"); ("pid", "99") ] -> () 336 + | _ -> Alcotest.fail "expected uid=42 pid=99" 337 + 338 + let test_route_param_decode () = 339 + let routes = [ Respond.get "/users/:name" ok ] in 340 + match bindings_of "/users/john%20doe" routes with 341 + | Some [ ("name", "john doe") ] -> () 342 + | _ -> Alcotest.fail "expected decoded name" 343 + 344 + let test_route_catchall () = 345 + let routes = [ Respond.get "/:branch/**" ok ] in 346 + match bindings_of "/main/src/main.ml" routes with 347 + | Some [ ("branch", "main"); ("rest", "src/main.ml") ] -> () 348 + | _ -> Alcotest.fail "expected branch+rest" 349 + 350 + let test_route_catchall_empty () = 351 + let routes = [ Respond.get "/:branch/**" ok ] in 352 + match bindings_of "/main" routes with 353 + | Some [ ("branch", "main"); ("rest", "") ] -> () 354 + | _ -> Alcotest.fail "expected empty rest" 355 + 356 + let test_route_first_wins () = 357 + let routes = 358 + [ Respond.get "/blocks/:hash" ok; Respond.get "/:branch/**" ok ] 359 + in 360 + match bindings_of "/blocks/abc" routes with 361 + | Some [ ("hash", "abc") ] -> () 362 + | _ -> Alcotest.fail "expected /blocks/:hash to win via ordering" 363 + 364 + let test_route_catchall_misplaced () = 365 + check_bool "raises on ** not last" true 366 + (try 367 + let _ = Respond.get "/**/trailing" ok in 368 + false 369 + with Invalid_argument _ -> true) 370 + 371 + (* ── Adversarial & edge cases ─────────────────────────────────────── *) 372 + 373 + let test_root_pattern () = 374 + let routes = [ Respond.get "/" ok ] in 375 + check_bool "matches /" true (Respond.match_route routes "/" <> None); 376 + check_bool "does not match /foo" true 377 + (Respond.match_route routes "/foo" = None) 378 + 379 + let test_catchall_only () = 380 + (* Pattern "**" alone catches everything, including root. *) 381 + let routes = [ Respond.get "/**" ok ] in 382 + (match bindings_of "/" routes with 383 + | Some [ ("rest", "") ] -> () 384 + | _ -> Alcotest.fail "expected rest='' on root"); 385 + match bindings_of "/a/b/c" routes with 386 + | Some [ ("rest", "a/b/c") ] -> () 387 + | _ -> Alcotest.fail "expected rest='a/b/c'" 388 + 389 + let test_trailing_slash_matches_bare () = 390 + (* Request /foo/ and /foo both split to ["foo"]; both match /:name. *) 391 + let routes = [ Respond.get "/:name" ok ] in 392 + check_bool "matches /foo" true (Respond.match_route routes "/foo" <> None); 393 + check_bool "matches /foo/" true (Respond.match_route routes "/foo/" <> None) 394 + 395 + let test_consecutive_slashes () = 396 + (* // is treated the same as / (empty segments filtered). Documents 397 + behaviour; callers that care should reject weird paths upstream. *) 398 + let routes = [ Respond.get "/a/b" ok ] in 399 + check_bool "matches //a//b" true (Respond.match_route routes "//a//b" <> None) 400 + 401 + let test_too_few_segments () = 402 + let routes = [ Respond.get "/users/:id/posts" ok ] in 403 + check_bool "no match /users" true (Respond.match_route routes "/users" = None); 404 + check_bool "no match /users/42" true 405 + (Respond.match_route routes "/users/42" = None) 406 + 407 + let test_too_many_segments () = 408 + let routes = [ Respond.get "/users/:id" ok ] in 409 + check_bool "no match extra segment" true 410 + (Respond.match_route routes "/users/42/extra" = None) 411 + 412 + let test_literal_wins_over_param () = 413 + (* Literal route registered first takes priority over a param route. *) 414 + let routes = [ Respond.get "/users/me" ok; Respond.get "/users/:id" ok ] in 415 + match bindings_of "/users/me" routes with 416 + | Some [] -> () 417 + | _ -> Alcotest.fail "expected empty bindings (literal route)" 418 + 419 + let test_param_after_literal () = 420 + let routes = [ Respond.get "/users/me" ok; Respond.get "/users/:id" ok ] in 421 + match bindings_of "/users/42" routes with 422 + | Some [ ("id", "42") ] -> () 423 + | _ -> Alcotest.fail "expected id=42 fallthrough to param" 424 + 425 + let test_param_captures_dot () = 426 + (* Dotfiles and extensions should end up in a single param segment. *) 427 + let routes = [ Respond.get "/files/:name" ok ] in 428 + match bindings_of "/files/README.md" routes with 429 + | Some [ ("name", "README.md") ] -> () 430 + | _ -> Alcotest.fail "expected README.md" 431 + 432 + let test_param_captures_dotdot_literal () = 433 + (* Handlers MUST NOT treat captured segments as filesystem paths. This 434 + test documents that ".." is passed through as a literal segment value; 435 + it is the handler's responsibility to reject it if context requires. *) 436 + let routes = [ Respond.get "/files/:name" ok ] in 437 + match bindings_of "/files/.." routes with 438 + | Some [ ("name", "..") ] -> () 439 + | _ -> Alcotest.fail "expected name='..'" 440 + 441 + let test_param_rejects_encoded_slash () = 442 + (* %2F decodes to '/' inside a captured segment. Pattern /:name matches 443 + because split happens on raw slashes before pct-decode. The captured 444 + value then contains the literal '/'. Documented so callers can sanitise. *) 445 + let routes = [ Respond.get "/tag/:name" ok ] in 446 + match bindings_of "/tag/foo%2Fbar" routes with 447 + | Some [ ("name", "foo/bar") ] -> () 448 + | _ -> Alcotest.fail "expected decoded '/'" 449 + 450 + let test_param_unicode () = 451 + let routes = [ Respond.get "/u/:name" ok ] in 452 + match bindings_of "/u/café" routes with 453 + | Some [ ("name", "café") ] -> () 454 + | _ -> Alcotest.fail "expected café" 455 + 456 + let test_param_unicode_encoded () = 457 + let routes = [ Respond.get "/u/:name" ok ] in 458 + match bindings_of "/u/caf%C3%A9" routes with 459 + | Some [ ("name", "café") ] -> () 460 + | _ -> Alcotest.fail "expected decoded café" 461 + 462 + let test_catchall_preserves_slashes () = 463 + let routes = [ Respond.get "/repo/**" ok ] in 464 + match bindings_of "/repo/a/b/c/d" routes with 465 + | Some [ ("rest", "a/b/c/d") ] -> () 466 + | _ -> Alcotest.fail "expected rest='a/b/c/d'" 467 + 468 + let test_catchall_one_segment () = 469 + let routes = [ Respond.get "/repo/**" ok ] in 470 + match bindings_of "/repo/only" routes with 471 + | Some [ ("rest", "only") ] -> () 472 + | _ -> Alcotest.fail "expected rest='only'" 473 + 474 + let test_catchall_after_param () = 475 + let routes = [ Respond.get "/:branch/**" ok ] in 476 + match bindings_of "/main/src/deep/file.ml" routes with 477 + | Some [ ("branch", "main"); ("rest", "src/deep/file.ml") ] -> () 478 + | _ -> Alcotest.fail "expected branch+rest" 479 + 480 + let test_pattern_compile_empty () = 481 + (* Empty pattern "" compiles to no segments; matches only the empty path. *) 482 + let routes = [ Respond.get "" ok ] in 483 + check_bool "matches /" true (Respond.match_route routes "/" <> None); 484 + check_bool "no match /a" true (Respond.match_route routes "/a" = None) 485 + 486 + let test_pattern_colon_in_literal () = 487 + (* A colon NOT at position 0 of a segment must remain literal. *) 488 + let routes = [ Respond.get "/api/v1:batch" ok ] in 489 + check_bool "literal with colon matches" true 490 + (Respond.match_route routes "/api/v1:batch" <> None); 491 + check_bool "plain name does not match" true 492 + (Respond.match_route routes "/api/anything" = None) 493 + 494 + let test_pattern_bare_colon () = 495 + (* A bare ":" (len 1, leading colon but no name) must stay literal. *) 496 + let routes = [ Respond.get "/a/:/b" ok ] in 497 + check_bool "bare colon literal" true 498 + (Respond.match_route routes "/a/:/b" <> None); 499 + check_bool "does not behave as wildcard" true 500 + (Respond.match_route routes "/a/x/b" = None) 501 + 502 + let test_empty_param_segment () = 503 + (* Double slash in request produces empty segment that gets filtered. 504 + Therefore /users//42 effectively equals /users/42 under our matcher. *) 505 + let routes = [ Respond.get "/users/:id" ok ] in 506 + match bindings_of "/users//42" routes with 507 + | Some [ ("id", "42") ] -> () 508 + | _ -> Alcotest.fail "expected id=42 after empty-segment filter" 509 + 510 + let test_deep_path_perf () = 511 + (* Ensure deep paths do not stack-overflow. 1000 segments. *) 512 + let pattern = "/" ^ String.concat "/" (List.init 1000 (fun _ -> "x")) in 513 + let path = pattern in 514 + let routes = [ Respond.get pattern ok ] in 515 + check_bool "matches 1000-seg path" true 516 + (Respond.match_route routes path <> None) 517 + 518 + let test_deep_catchall_perf () = 519 + let path = 520 + "/" ^ String.concat "/" (List.init 1000 (fun i -> string_of_int i)) 521 + in 522 + let routes = [ Respond.get "/**" ok ] in 523 + match bindings_of path routes with 524 + | Some [ ("rest", _) ] -> () 525 + | _ -> Alcotest.fail "expected 1000-seg catchall" 526 + 527 + let test_unrelated_path_against_param () = 528 + let routes = [ Respond.get "/users/:id" ok ] in 529 + check_bool "totally different path" true 530 + (Respond.match_route routes "/orders/42" = None) 531 + 532 + let test_catchall_not_greedy_past_pattern () = 533 + (* A literal segment after a param must still match. *) 534 + let routes = [ Respond.get "/auth/:slug/callback" ok ] in 535 + check_bool "no match without /callback" true 536 + (Respond.match_route routes "/auth/github" = None); 537 + check_bool "no match with extra after callback" true 538 + (Respond.match_route routes "/auth/github/callback/extra" = None); 539 + match bindings_of "/auth/github/callback" routes with 540 + | Some [ ("slug", "github") ] -> () 541 + | _ -> Alcotest.fail "expected slug=github" 542 + 543 + let test_bindings_order_is_pattern_order () = 544 + (* path_params must come out in the order they appear in the pattern. *) 545 + let routes = [ Respond.get "/:a/:b/:c" ok ] in 546 + match bindings_of "/x/y/z" routes with 547 + | Some [ ("a", "x"); ("b", "y"); ("c", "z") ] -> () 548 + | _ -> Alcotest.fail "expected a,b,c in that order" 549 + 550 + let test_malformed_pct () = 551 + (* Invalid percent-encoding ("%Z1") is passed through literally by 552 + pct_decode. Documents that it does not blow up. *) 553 + let routes = [ Respond.get "/x/:v" ok ] in 554 + match bindings_of "/x/%Z1" routes with 555 + | Some [ ("v", "%Z1") ] -> () 556 + | _ -> Alcotest.fail "expected literal %Z1" 557 + 558 + let test_query_does_not_affect_match () = 559 + (* Handled at the request layer (parse_url strips ?query before matching), 560 + so this test drives the matcher with a path-only input. *) 561 + let path, _ = Respond.parse_url "/users/42?sort=asc" in 562 + let routes = [ Respond.get "/users/:id" ok ] in 563 + match bindings_of path routes with 564 + | Some [ ("id", "42") ] -> () 565 + | _ -> Alcotest.fail "expected id=42" 566 + 567 + let test_multiple_catchall_misplaced () = 568 + check_bool "double ** raises" true 569 + (try 570 + let _ = Respond.get "/a/**/b/**" ok in 571 + false 572 + with Invalid_argument _ -> true) 573 + 326 574 let route_tests = 327 575 [ 328 576 ("exact match", `Quick, test_route_exact); 329 577 ("no match", `Quick, test_route_no_match); 330 578 ("no prefix match", `Quick, test_route_no_prefix); 579 + ("named param", `Quick, test_route_param); 580 + ("multiple params", `Quick, test_route_multiple_params); 581 + ("param pct-decoded", `Quick, test_route_param_decode); 582 + ("catch-all **", `Quick, test_route_catchall); 583 + ("catch-all empty rest", `Quick, test_route_catchall_empty); 584 + ("first route wins", `Quick, test_route_first_wins); 585 + ("** must be last", `Quick, test_route_catchall_misplaced); 586 + ("root pattern", `Quick, test_root_pattern); 587 + ("catch-all only", `Quick, test_catchall_only); 588 + ("trailing slash matches bare", `Quick, test_trailing_slash_matches_bare); 589 + ("consecutive slashes", `Quick, test_consecutive_slashes); 590 + ("too few segments", `Quick, test_too_few_segments); 591 + ("too many segments", `Quick, test_too_many_segments); 592 + ("literal beats param when first", `Quick, test_literal_wins_over_param); 593 + ("param fallback after literal", `Quick, test_param_after_literal); 594 + ("param captures dot extension", `Quick, test_param_captures_dot); 595 + ("param captures .. as literal", `Quick, test_param_captures_dotdot_literal); 596 + ("encoded / inside param", `Quick, test_param_rejects_encoded_slash); 597 + ("unicode in param raw", `Quick, test_param_unicode); 598 + ("unicode in param encoded", `Quick, test_param_unicode_encoded); 599 + ("catchall preserves slashes", `Quick, test_catchall_preserves_slashes); 600 + ("catchall one segment", `Quick, test_catchall_one_segment); 601 + ("catchall after param", `Quick, test_catchall_after_param); 602 + ("empty pattern", `Quick, test_pattern_compile_empty); 603 + ("colon mid-literal", `Quick, test_pattern_colon_in_literal); 604 + ("bare colon literal", `Quick, test_pattern_bare_colon); 605 + ("empty segment filter", `Quick, test_empty_param_segment); 606 + ("deep path perf", `Quick, test_deep_path_perf); 607 + ("deep catchall perf", `Quick, test_deep_catchall_perf); 608 + ("unrelated path", `Quick, test_unrelated_path_against_param); 609 + ("exact chain with :param", `Quick, test_catchall_not_greedy_past_pattern); 610 + ("bindings preserve order", `Quick, test_bindings_order_is_pattern_order); 611 + ("malformed pct-encoding", `Quick, test_malformed_pct); 612 + ("query stripped before match", `Quick, test_query_does_not_affect_match); 613 + ("multiple ** raises", `Quick, test_multiple_catchall_misplaced); 331 614 ] 332 615 333 616 (* ── Runner ────────────────────────────────────────────────────────── *)