ocaml http/1, http/2 and websocket client and server library

refactor: Phoenix-style three-layer plug architecture (v0.2.0)

Implement three-layer request flow: Endpoint → Router (scopes) → Route

New features:
- Pipeline module for reusable plug collections
- Router.scope for grouping routes with shared plugs
- Route.plug for per-route middleware
- Endpoint.to_handler replaces Endpoint.start (separation of concerns)

API changes:
- Endpoint no longer manages server config (port/bind/tls)
- Server.run now accepts config directly
- Router.compile_scopes for scope-based routing

Includes documentation updates for new API patterns.

+3 -2
README.md
··· 8 8 |--------|-------------| 9 9 | [Client](lib/client.ml) | HTTP client with auto-protocol selection and connection pooling | 10 10 | [Server](lib/server.ml) | HTTP server with multi-domain parallelism | 11 - | [Router](lib/router.ml) | Radix trie router with path parameters | 11 + | [Router](lib/router.ml) | Radix trie router with path parameters, scopes, and per-route plugs | 12 12 | [Plug](lib/plug.ml) | Phoenix-style middleware composition | 13 - | [Endpoint](lib/endpoint.ml) | Application bootstrap tying router, plugs, and server | 13 + | [Pipeline](lib/pipeline.ml) | Reusable plug collections for route scopes | 14 + | [Endpoint](lib/endpoint.ml) | Global plug entry point, builds handler from plugs + router | 14 15 | [Websocket](lib/websocket.ml) | WebSocket client and server (RFC 6455) | 15 16 | [Sse](lib/sse.ml) | Server-Sent Events | 16 17 | [Pubsub](lib/pubsub.ml) | Lock-free topic-based pub/sub messaging |
+10 -5
bin/las/README.md
··· 47 47 let () = 48 48 Eio_main.run @@ fun env -> 49 49 let clock = Eio.Stdenv.clock env in 50 + let net = Eio.Stdenv.net env in 50 51 let store = Plug.Session.Memory_store.create () in 51 52 52 53 (* Build the plug pipeline *) 53 54 let endpoint = 54 55 Endpoint.create 55 - { Endpoint.default_config with 56 - port = 8080; 57 - secret_key_base = Sys.getenv_opt "SECRET_KEY" |> Option.value ~default:"dev-secret-32-chars-minimum!!!"; 56 + { secret_key_base = Sys.getenv_opt "SECRET_KEY" 57 + |> Option.value ~default:"dev-secret-32-chars-minimum!!!"; 58 + health_check = true; 58 59 } 59 60 |> Endpoint.plug (Plug.Logger.create ~clock (fun _ _ -> ())) 60 61 |> Endpoint.plug (Plug.Compress.create ()) ··· 71 72 ~requests:100 72 73 ~per:60.0) 73 74 |> Endpoint.router Routes.router 74 - |> Endpoint.websocket Realtime.ws_handler 75 75 in 76 76 77 - Endpoint.start endpoint ~env 77 + (* Build handler and run server *) 78 + let handler = Endpoint.to_handler endpoint in 79 + Eio.Switch.run @@ fun sw -> 80 + Server.run ~sw ~net 81 + ~config:{ Server.default_config with port = 8080 } 82 + handler 78 83 ``` 79 84 80 85 ### 1.2 Data Models
+12 -8
bin/las/las.ml
··· 27 27 28 28 Eio_main.run @@ fun env -> 29 29 let clock = Eio.Stdenv.clock env in 30 + let net = Eio.Stdenv.net env in 30 31 (match Db.init db_path with 31 32 | Ok () -> () 32 33 | Error e -> failwith ("Database init failed: " ^ e)); ··· 49 50 Printf.printf "Database: %s\n%!" db_path; 50 51 if verbose then Printf.printf "Verbose logging: enabled\n%!"; 51 52 53 + let secret_key = 54 + Sys.getenv_opt "SECRET_KEY" 55 + |> Option.value ~default:"dev-secret-32-chars-minimum!!!" 56 + in 57 + 52 58 let endpoint = 53 59 let e = 54 60 Hcs.Endpoint.create 55 - { 56 - Hcs.Endpoint.default_config with 57 - port; 58 - secret_key_base = 59 - Sys.getenv_opt "SECRET_KEY" 60 - |> Option.value ~default:"dev-secret-32-chars-minimum!!!"; 61 - } 61 + { Hcs.Endpoint.default_config with secret_key_base = secret_key } 62 62 in 63 63 let e = Hcs.Endpoint.plug e (Hcs.Plug.Logger.create ~clock logger) in 64 64 let e = Hcs.Endpoint.plug e (Hcs.Plug.Compress.create ()) in ··· 84 84 in 85 85 Hcs.Endpoint.router e (Routes.router clock) 86 86 in 87 - Hcs.Endpoint.start endpoint ~env 87 + 88 + let handler = Hcs.Endpoint.to_handler endpoint in 89 + let config = { Hcs.Server.default_config with port } in 90 + 91 + Eio.Switch.run @@ fun sw -> Hcs.Server.run ~sw ~net ~config handler 88 92 89 93 let () = Climate.Command.run command
+1 -1
docs/guides/plug-system.md
··· 251 251 252 252 Eio.Switch.run @@ fun sw -> 253 253 let net = Eio.Stdenv.net env in 254 - Server.run ~sw ~net handler 254 + Server.run ~sw ~net ~config:Server.default_config handler 255 255 ``` 256 256 257 257 ## Next Steps
+9 -3
docs/guides/routing.md
··· 180 180 181 181 let () = 182 182 Eio_main.run @@ fun env -> 183 + let net = Eio.Stdenv.net env in 183 184 let endpoint = 184 185 Endpoint.create Endpoint.default_config 185 186 |> Endpoint.router routes 186 187 in 187 - Endpoint.start endpoint ~env 188 + let handler = Endpoint.to_handler endpoint in 189 + Eio.Switch.run @@ fun sw -> 190 + Server.run ~sw ~net ~config:{ Server.default_config with port = 8080 } handler 188 191 ``` 189 192 190 193 ## Route Organization ··· 233 236 234 237 let () = 235 238 Eio_main.run @@ fun env -> 239 + let net = Eio.Stdenv.net env in 236 240 let endpoint = 237 - Endpoint.create { Endpoint.default_config with port = 8080 } 241 + Endpoint.create Endpoint.default_config 238 242 |> Endpoint.router routes 239 243 in 240 - Endpoint.start endpoint ~env 244 + let handler = Endpoint.to_handler endpoint in 245 + Eio.Switch.run @@ fun sw -> 246 + Server.run ~sw ~net ~config:{ Server.default_config with port = 8080 } handler 241 247 ``` 242 248 243 249 Test it:
+1 -1
dune-project
··· 2 2 3 3 (name hcs) 4 4 5 - (version 0.1.1) 5 + (version 0.2.0) 6 6 7 7 (generate_opam_files true) 8 8
+1 -1
hcs.opam
··· 1 1 # This file is generated by dune, edit dune-project instead 2 2 opam-version: "2.0" 3 - version: "0.1.1" 3 + version: "0.2.0" 4 4 synopsis: "Eio based HTTP client/server library for OCaml 5+" 5 5 description: 6 6 "HCS is a HTTP client/server library for OCaml 5+ supporting HTTP/1.1, HTTP/2, and WebSocket. Built on Eio."
+17 -50
lib/endpoint.ml
··· 1 - type config = { 2 - port : int; 3 - bind : string; 4 - domains : int; 5 - secret_key_base : string; 6 - protocol : Server.protocol; 7 - tls : Tls_config.Server.t option; 8 - health_check : bool; 9 - } 1 + type config = { secret_key_base : string; health_check : bool } 10 2 11 - let default_config = 12 - { 13 - port = 8080; 14 - bind = "0.0.0.0"; 15 - domains = 1; 16 - secret_key_base = ""; 17 - protocol = Server.Http1_only; 18 - tls = None; 19 - health_check = true; 20 - } 3 + let default_config = { secret_key_base = ""; health_check = true } 21 4 22 5 type params_handler = Router.params -> Server.request -> Server.response 23 6 24 7 type t = { 25 8 config : config; 26 - plugs : Plug.t list; 9 + plugs : Pipeline.t; 27 10 router : params_handler Router.t option; 28 11 ws_handler : Server.ws_handler option; 29 12 } 30 13 31 - let create config = { config; plugs = []; router = None; ws_handler = None } 32 - let plug t p = { t with plugs = p :: t.plugs } 14 + let create config = 15 + { config; plugs = Pipeline.empty; router = None; ws_handler = None } 16 + 17 + let plug t p = { t with plugs = Pipeline.plug t.plugs p } 33 18 let router t r = { t with router = Some r } 34 19 let websocket t handler = { t with ws_handler = Some handler } 20 + let ws_handler t = t.ws_handler 35 21 let not_found_handler _req = Server.respond ~status:`Not_found "Not Found" 36 22 37 23 let health_handler _req = 38 24 Server.respond ~status:`OK ~headers:[ ("Content-Type", "text/plain") ] "ok" 39 25 40 - let build_handler t = 41 - let base_handler = 26 + let to_handler t = 27 + let route_handler = 42 28 match t.router with 43 29 | None -> not_found_handler 44 30 | Some r -> ( ··· 49 35 | None -> req.target 50 36 in 51 37 match Router.lookup r ~method_:req.meth ~path with 52 - | Some (handler, params) -> handler params req 38 + | Some { handler; params; plugs } -> 39 + let base_handler req = handler params req in 40 + let wrapped = Pipeline.apply plugs base_handler in 41 + wrapped req 53 42 | None -> not_found_handler req) 54 43 in 55 44 let with_health = 56 45 if t.config.health_check then fun req -> 57 46 if req.Server.target = "/_health" || req.Server.target = "/health" then 58 47 health_handler req 59 - else base_handler req 60 - else base_handler 48 + else route_handler req 49 + else route_handler 61 50 in 62 - let pipeline = Plug.compose_all (List.rev t.plugs) in 63 - Plug.apply pipeline with_health 64 - 65 - let start t ~env = 66 - let handler = build_handler t in 67 - let server_config = 68 - { 69 - Server.default_config with 70 - port = t.config.port; 71 - host = t.config.bind; 72 - domain_count = t.config.domains; 73 - protocol = t.config.protocol; 74 - tls = t.config.tls; 75 - } 76 - in 77 - Eio.Switch.run @@ fun sw -> 78 - let net = Eio.Stdenv.net env in 79 - if t.config.domains > 1 then 80 - let domain_mgr = Eio.Stdenv.domain_mgr env in 81 - Server.run_parallel ~sw ~net ~domain_mgr ~config:server_config 82 - ?ws_handler:t.ws_handler handler 83 - else 84 - Server.run ~sw ~net ~config:server_config ?ws_handler:t.ws_handler handler 51 + Pipeline.apply t.plugs with_health
+4 -1
lib/hcs.ml
··· 18 18 module Plug = Plug 19 19 (** Plug-based middleware system (Phoenix-style) *) 20 20 21 + module Pipeline = Pipeline 22 + (** Reusable plug collections for route scopes *) 23 + 21 24 module Endpoint = Endpoint 22 - (** Application bootstrap tying router, plugs, and server *) 25 + (** Global plug entry point before routing *) 23 26 24 27 module Pool = Pool 25 28 (** Connection pool *)
+16
lib/pipeline.ml
··· 1 + (** Reusable plug collections for route groups (Phoenix-style pipelines). *) 2 + 3 + type t = Core.t list 4 + 5 + let empty : t = [] 6 + let create (plugs : Core.t list) : t = plugs 7 + let plug (t : t) (p : Core.t) : t = t @ [ p ] 8 + let plug_first (t : t) (p : Core.t) : t = p :: t 9 + let compose (t1 : t) (t2 : t) : t = t1 @ t2 10 + let to_plug (t : t) : Core.t = Core.compose_all t 11 + 12 + let apply (t : t) (handler : Core.handler) : Core.handler = 13 + Core.apply (to_plug t) handler 14 + 15 + let is_empty (t : t) : bool = t = [] 16 + let length (t : t) : int = List.length t
+67 -57
lib/router.ml
··· 1 - (** Type-safe router with radix trie for efficient path matching. 2 - 3 - This module provides: 4 - - Type-safe path patterns with parameter extraction 5 - - Radix trie for O(path_length) route lookup 6 - - Hashtbl for O(1) literal segment matching 7 - - Middleware support 8 - - Route scoping/grouping *) 9 - 10 - (** Path segment types *) 11 - type segment = 12 - | Literal of string (** Exact match *) 13 - | Param of string (** Named parameter capture *) 14 - | Wildcard (** Match rest of path *) 1 + (** Type-safe router with radix trie, scopes, and per-route plugs. *) 15 2 3 + type segment = Literal of string | Param of string | Wildcard 16 4 type params = (string * string) list 17 - (** Parsed path parameters *) 18 - 19 - type 'a route = { 20 - method_ : H1.Method.t option; (** None = match any method *) 21 - segments : segment list; 22 - handler : 'a; 23 - } 24 - (** A route definition *) 5 + type 'a handler_entry = { handler : 'a; plugs : Pipeline.t } 25 6 26 7 type 'a trie_node = { 27 - mutable handlers : (H1.Method.t option * 'a) list; 28 - (** Handlers at this node *) 8 + mutable handlers : (H1.Method.t option * 'a handler_entry) list; 29 9 literal_children : (string, 'a trie_node) Hashtbl.t; 30 - (** O(1) lookup for literal segments *) 31 10 mutable param_child : (string * 'a trie_node) option; 32 - (** Single param child (with param name) *) 33 - mutable wildcard_child : 'a trie_node option; (** Single wildcard child *) 11 + mutable wildcard_child : 'a trie_node option; 34 12 } 35 - (** Radix trie node with optimized child storage *) 36 13 37 14 type 'a t = { root : 'a trie_node } 38 - (** Compiled router *) 15 + type 'a match_result = { handler : 'a; params : params; plugs : Pipeline.t } 39 16 40 - (** Create empty trie node *) 41 17 let empty_node () = 42 18 { 43 19 handlers = []; ··· 46 22 wildcard_child = None; 47 23 } 48 24 49 - (** Create empty router *) 50 25 let empty () = { root = empty_node () } 51 26 52 - (** Parse path string into segments *) 53 27 let parse_path path = 54 28 let path = 55 29 if String.length path > 0 && path.[0] = '/' then ··· 66 40 else if s = "*" then Wildcard 67 41 else Literal s) 68 42 69 - (** Find or create child node for segment *) 70 43 let find_or_create_child node seg = 71 44 match seg with 72 45 | Literal s -> ( ··· 91 64 node.wildcard_child <- Some child; 92 65 child) 93 66 94 - (** Add a route to the trie *) 95 - let add_route router ~method_ ~path ~handler = 67 + let add_route router ~method_ ~path ~handler ~plugs = 96 68 let segments = parse_path path in 69 + let entry = { handler; plugs } in 97 70 let rec insert node = function 98 - | [] -> node.handlers <- (method_, handler) :: node.handlers 71 + | [] -> node.handlers <- (method_, entry) :: node.handlers 99 72 | seg :: rest -> 100 73 let child = find_or_create_child node seg in 101 74 insert child rest 102 75 in 103 76 insert router.root segments 104 77 105 - (** Lookup a path in the trie - optimized with index-based parsing *) 106 78 let lookup router ~method_ ~path = 107 79 let len = String.length path in 108 80 let start = if len > 0 && path.[0] = '/' then 1 else 0 in 109 81 110 82 let rec search node pos params = 111 83 if pos >= len then 112 - List.find_opt 113 - (fun (m, _) -> match m with None -> true | Some m' -> m' = method_) 114 - node.handlers 115 - |> Option.map (fun (_, handler) -> (handler, params)) 84 + match 85 + List.find_opt 86 + (fun (m, _) -> match m with None -> true | Some m' -> m' = method_) 87 + node.handlers 88 + with 89 + | Some (_, entry) -> 90 + Some { handler = entry.handler; params; plugs = entry.plugs } 91 + | None -> None 116 92 else 117 93 let seg_end = 118 94 try String.index_from path pos '/' with Not_found -> len ··· 135 111 in 136 112 search router.root start [] 137 113 138 - (** Route builder DSL *) 139 114 module Route = struct 140 - type 'a t = { method_ : H1.Method.t option; path : string; handler : 'a } 115 + type 'a t = { 116 + method_ : H1.Method.t option; 117 + path : string; 118 + handler : 'a; 119 + plugs : Pipeline.t; 120 + } 141 121 142 - let get path handler = { method_ = Some `GET; path; handler } 143 - let post path handler = { method_ = Some `POST; path; handler } 144 - let put path handler = { method_ = Some `PUT; path; handler } 145 - let delete path handler = { method_ = Some `DELETE; path; handler } 146 - let patch path handler = { method_ = Some (`Other "PATCH"); path; handler } 147 - let head path handler = { method_ = Some `HEAD; path; handler } 148 - let options path handler = { method_ = Some `OPTIONS; path; handler } 149 - let any path handler = { method_ = None; path; handler } 122 + let make ?method_ path handler = { method_; path; handler; plugs = [] } 123 + let get path handler = { method_ = Some `GET; path; handler; plugs = [] } 124 + let post path handler = { method_ = Some `POST; path; handler; plugs = [] } 125 + let put path handler = { method_ = Some `PUT; path; handler; plugs = [] } 126 + 127 + let delete path handler = 128 + { method_ = Some `DELETE; path; handler; plugs = [] } 129 + 130 + let patch path handler = 131 + { method_ = Some (`Other "PATCH"); path; handler; plugs = [] } 132 + 133 + let head path handler = { method_ = Some `HEAD; path; handler; plugs = [] } 134 + 135 + let options path handler = 136 + { method_ = Some `OPTIONS; path; handler; plugs = [] } 137 + 138 + let any path handler = { method_ = None; path; handler; plugs = [] } 139 + let plug route p = { route with plugs = Pipeline.plug route.plugs p } 150 140 end 151 141 152 - (** Compile routes into a router *) 142 + let normalize_path path = 143 + if String.length path = 0 then "/" 144 + else if path.[0] <> '/' then "/" ^ path 145 + else path 146 + 147 + let join_paths prefix path = 148 + let prefix = normalize_path prefix in 149 + let path = normalize_path path in 150 + if prefix = "/" then path else if path = "/" then prefix else prefix ^ path 151 + 152 + let scope ?(through = Pipeline.empty) prefix routes = 153 + List.map 154 + (fun (r : 'a Route.t) -> 155 + { 156 + r with 157 + path = join_paths prefix r.path; 158 + plugs = Pipeline.compose through r.plugs; 159 + }) 160 + routes 161 + 153 162 let compile routes = 154 163 let router = empty () in 155 164 List.iter 156 165 (fun r -> 157 - add_route router ~method_:r.Route.method_ ~path:r.path ~handler:r.handler) 166 + add_route router ~method_:r.Route.method_ ~path:r.Route.path 167 + ~handler:r.Route.handler ~plugs:r.Route.plugs) 158 168 routes; 159 169 router 160 170 161 - (** Get parameter from params list *) 171 + let compile_scopes scopes = 172 + let all_routes = List.concat scopes in 173 + compile all_routes 174 + 162 175 let param name params = List.assoc_opt name params 163 176 164 - (** Get parameter with default *) 165 177 let param_or name ~default params = 166 178 match List.assoc_opt name params with Some v -> v | None -> default 167 179 168 - (** Get parameter as int *) 169 180 let param_int name params = 170 181 match List.assoc_opt name params with 171 182 | Some v -> int_of_string_opt v 172 183 | None -> None 173 184 174 - (** Get parameter as int with default *) 175 185 let param_int_or name ~default params = 176 186 match param_int name params with Some v -> v | None -> default
+129 -24
test/test_hcs.ml
··· 445 445 ] 446 446 end 447 447 448 - (* ================================================================== *) 449 - (* Router Tests *) 450 - (* ================================================================== *) 448 + module Test_pipeline = struct 449 + open Hcs.Pipeline 450 + 451 + let test_empty () = check int "empty length" 0 (length empty) 452 + 453 + let test_create () = 454 + let p = create [ Fun.id; Fun.id ] in 455 + check int "create length" 2 (length p) 456 + 457 + let test_plug () = 458 + let p = empty |> fun t -> plug t Fun.id in 459 + check int "plug length" 1 (length p) 460 + 461 + let test_plug_first () = 462 + let p = empty |> fun t -> plug_first t Fun.id in 463 + check int "plug_first length" 1 (length p) 464 + 465 + let test_compose () = 466 + let p1 = create [ Fun.id ] in 467 + let p2 = create [ Fun.id; Fun.id ] in 468 + let combined = compose p1 p2 in 469 + check int "compose length" 3 (length combined) 470 + 471 + let test_is_empty () = 472 + check bool "empty is empty" true (is_empty empty); 473 + check bool "non-empty not empty" false (is_empty (create [ Fun.id ])) 474 + 475 + let test_apply () = 476 + let called = ref false in 477 + let mark_plug : Hcs.Plug.t = 478 + fun handler req -> 479 + called := true; 480 + handler req 481 + in 482 + let p = create [ mark_plug ] in 483 + let dummy_req : Hcs.Server.request = 484 + { meth = `GET; target = "/"; headers = []; body = ""; version = HTTP_1_1 } 485 + in 486 + let handler _req = Hcs.Server.respond "ok" in 487 + let wrapped = apply p handler in 488 + let _ = wrapped dummy_req in 489 + check bool "plug was called" true !called 490 + 491 + let tests = 492 + [ 493 + test_case "empty" `Quick test_empty; 494 + test_case "create" `Quick test_create; 495 + test_case "plug" `Quick test_plug; 496 + test_case "plug_first" `Quick test_plug_first; 497 + test_case "compose" `Quick test_compose; 498 + test_case "is_empty" `Quick test_is_empty; 499 + test_case "apply" `Quick test_apply; 500 + ] 501 + end 451 502 452 503 module Test_router = struct 453 504 open Hcs.Router ··· 488 539 let test_router_literal_match () = 489 540 let router = empty () in 490 541 add_route router ~method_:(Some `GET) ~path:"/users" 491 - ~handler:"users_handler"; 542 + ~handler:"users_handler" ~plugs:[]; 492 543 match lookup router ~method_:`GET ~path:"/users" with 493 - | Some (handler, _) -> check string "handler" "users_handler" handler 544 + | Some { handler; _ } -> check string "handler" "users_handler" handler 494 545 | None -> fail "no match" 495 546 496 547 let test_router_param_match () = 497 548 let router = empty () in 498 549 add_route router ~method_:(Some `GET) ~path:"/users/:id" 499 - ~handler:"user_handler"; 550 + ~handler:"user_handler" ~plugs:[]; 500 551 match lookup router ~method_:`GET ~path:"/users/123" with 501 - | Some (handler, params) -> 552 + | Some { handler; params; _ } -> 502 553 check string "handler" "user_handler" handler; 503 554 check (option string) "id param" (Some "123") (param "id" params) 504 555 | None -> fail "no match" ··· 506 557 let test_router_multiple_params () = 507 558 let router = empty () in 508 559 add_route router ~method_:(Some `GET) ~path:"/users/:user_id/posts/:post_id" 509 - ~handler:"post_handler"; 560 + ~handler:"post_handler" ~plugs:[]; 510 561 match lookup router ~method_:`GET ~path:"/users/42/posts/100" with 511 - | Some (handler, params) -> 562 + | Some { handler; params; _ } -> 512 563 check string "handler" "post_handler" handler; 513 564 check (option string) "user_id" (Some "42") (param "user_id" params); 514 565 check (option string) "post_id" (Some "100") (param "post_id" params) ··· 517 568 let test_router_wildcard_match () = 518 569 let router = empty () in 519 570 add_route router ~method_:(Some `GET) ~path:"/files/*" 520 - ~handler:"files_handler"; 571 + ~handler:"files_handler" ~plugs:[]; 521 572 match lookup router ~method_:`GET ~path:"/files/path/to/file.txt" with 522 - | Some (handler, params) -> 573 + | Some { handler; params; _ } -> 523 574 check string "handler" "files_handler" handler; 524 575 check (option string) "wildcard" (Some "path/to/file.txt") 525 576 (param "*" params) ··· 527 578 528 579 let test_router_method_match () = 529 580 let router = empty () in 530 - add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"get_handler"; 581 + add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"get_handler" 582 + ~plugs:[]; 531 583 add_route router ~method_:(Some `POST) ~path:"/users" 532 - ~handler:"post_handler"; 584 + ~handler:"post_handler" ~plugs:[]; 533 585 (match lookup router ~method_:`GET ~path:"/users" with 534 - | Some (handler, _) -> check string "GET handler" "get_handler" handler 586 + | Some { handler; _ } -> check string "GET handler" "get_handler" handler 535 587 | None -> fail "no GET match"); 536 588 match lookup router ~method_:`POST ~path:"/users" with 537 - | Some (handler, _) -> check string "POST handler" "post_handler" handler 589 + | Some { handler; _ } -> check string "POST handler" "post_handler" handler 538 590 | None -> fail "no POST match" 539 591 540 592 let test_router_any_method () = 541 593 let router = empty () in 542 - add_route router ~method_:None ~path:"/health" ~handler:"health_handler"; 594 + add_route router ~method_:None ~path:"/health" ~handler:"health_handler" 595 + ~plugs:[]; 543 596 (match lookup router ~method_:`GET ~path:"/health" with 544 - | Some (handler, _) -> check string "GET" "health_handler" handler 597 + | Some { handler; _ } -> check string "GET" "health_handler" handler 545 598 | None -> fail "no GET match"); 546 599 match lookup router ~method_:`POST ~path:"/health" with 547 - | Some (handler, _) -> check string "POST" "health_handler" handler 600 + | Some { handler; _ } -> check string "POST" "health_handler" handler 548 601 | None -> fail "no POST match" 549 602 550 603 let test_router_no_match () = 551 604 let router = empty () in 552 - add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"handler"; 605 + add_route router ~method_:(Some `GET) ~path:"/users" ~handler:"handler" 606 + ~plugs:[]; 553 607 match lookup router ~method_:`GET ~path:"/posts" with 554 608 | Some _ -> fail "unexpected match" 555 609 | None -> () ··· 565 619 in 566 620 let router = compile routes in 567 621 (match lookup router ~method_:`GET ~path:"/users" with 568 - | Some (h, _) -> check string "list" "list_users" h 622 + | Some { handler; _ } -> check string "list" "list_users" handler 569 623 | None -> fail "no list match"); 570 624 match lookup router ~method_:`GET ~path:"/users/42" with 571 - | Some (h, params) -> 572 - check string "get" "get_user" h; 625 + | Some { handler; params; _ } -> 626 + check string "get" "get_user" handler; 573 627 check (option string) "id" (Some "42") (param "id" params) 574 628 | None -> fail "no get match" 575 629 ··· 581 635 check (option int) "param_int" (Some 42) (param_int "id" params); 582 636 check int "param_int_or" 0 (param_int_or "foo" ~default:0 params) 583 637 638 + let test_scope_prefix () = 639 + let routes = scope "/api" [ Route.get "/users" "list_users" ] in 640 + let router = compile routes in 641 + match lookup router ~method_:`GET ~path:"/api/users" with 642 + | Some { handler; _ } -> check string "scoped handler" "list_users" handler 643 + | None -> fail "no match for scoped route" 644 + 645 + let test_scope_root_prefix () = 646 + let routes = scope "/" [ Route.get "/users" "list_users" ] in 647 + let router = compile routes in 648 + match lookup router ~method_:`GET ~path:"/users" with 649 + | Some { handler; _ } -> check string "root scope" "list_users" handler 650 + | None -> fail "no match" 651 + 652 + let test_scope_nested_path () = 653 + let routes = scope "/api/v1" [ Route.get "/users/:id" "get_user" ] in 654 + let router = compile routes in 655 + match lookup router ~method_:`GET ~path:"/api/v1/users/42" with 656 + | Some { handler; params; _ } -> 657 + check string "nested scope" "get_user" handler; 658 + check (option string) "id param" (Some "42") (param "id" params) 659 + | None -> fail "no match" 660 + 661 + let test_compile_scopes () = 662 + let api_routes = scope "/api" [ Route.get "/posts" "list_posts" ] in 663 + let web_routes = scope "/" [ Route.get "/" "home" ] in 664 + let router = compile_scopes [ api_routes; web_routes ] in 665 + (match lookup router ~method_:`GET ~path:"/api/posts" with 666 + | Some { handler; _ } -> check string "api route" "list_posts" handler 667 + | None -> fail "no api match"); 668 + match lookup router ~method_:`GET ~path:"/" with 669 + | Some { handler; _ } -> check string "web route" "home" handler 670 + | None -> fail "no web match" 671 + 672 + let test_route_with_plug () = 673 + let dummy_plug : Hcs.Plug.t = fun handler -> handler in 674 + let base_route = Route.get "/admin" "admin_handler" in 675 + let route = Route.plug base_route dummy_plug in 676 + let router = compile [ route ] in 677 + match lookup router ~method_:`GET ~path:"/admin" with 678 + | Some { handler; plugs; _ } -> 679 + check string "handler" "admin_handler" handler; 680 + check int "has plugs" 1 (List.length plugs) 681 + | None -> fail "no match" 682 + 584 683 let tests = 585 684 [ 586 685 test_case "parse_path empty" `Quick test_parse_path_empty; ··· 597 696 test_case "router no match" `Quick test_router_no_match; 598 697 test_case "router compile" `Quick test_router_compile; 599 698 test_case "param helpers" `Quick test_param_helpers; 699 + test_case "scope prefix" `Quick test_scope_prefix; 700 + test_case "scope root prefix" `Quick test_scope_root_prefix; 701 + test_case "scope nested path" `Quick test_scope_nested_path; 702 + test_case "compile scopes" `Quick test_compile_scopes; 703 + test_case "route with plug" `Quick test_route_with_plug; 600 704 ] 601 705 end 602 706 ··· 991 1095 let open Hcs.Router in 992 1096 let router = empty () in 993 1097 add_route router ~method_:(Some `GET) ~path:"/users/:id/items/:name" 994 - ~handler:"handler"; 1098 + ~handler:"handler" ~plugs:[]; 995 1099 match 996 1100 lookup router ~method_:`GET 997 1101 ~path:(Printf.sprintf "/users/%s/items/%s" id name) 998 1102 with 999 - | Some (_, params) -> 1103 + | Some { params; _ } -> 1000 1104 param "id" params = Some id && param "name" params = Some name 1001 1105 | None -> false) 1002 1106 ··· 1044 1148 ("Codec", Test_codec.tests); 1045 1149 ("Stream", Test_stream.tests); 1046 1150 ("Http", Test_http.tests); 1151 + ("Pipeline", Test_pipeline.tests); 1047 1152 ("Router", Test_router.tests); 1048 1153 ("Log", Test_log.tests); 1049 1154 ("Tls_config", Test_tls_config.tests);