diff --git a/opium/src/app.ml b/opium/src/app.ml index a85a3bb0..4c759061 100644 --- a/opium/src/app.ml +++ b/opium/src/app.ml @@ -2,6 +2,7 @@ open Import module Server = Httpaf_lwt_unix.Server module Reqd = Httpaf.Reqd open Lwt.Syntax +module Route = Router.Route let err_invalid_host host = Lwt.fail_invalid_arg ("Could not get host info for `" ^ host ^ "`") diff --git a/opium/src/middlewares/middleware_router.ml b/opium/src/middlewares/middleware_router.ml index 4d149a9d..d4ef402b 100644 --- a/opium/src/middlewares/middleware_router.ml +++ b/opium/src/middlewares/middleware_router.ml @@ -10,54 +10,48 @@ module Method_map = Map.Make (struct ;; end) -type 'a t = (Route.t * 'a) list Method_map.t +type t = Rock.Handler.t Method_map.t Router.t -let empty = Method_map.empty +let empty = Router.empty -let get t meth = - match Method_map.find_opt meth t with - | None -> [] - | Some xs -> List.rev xs -;; - -let add t ~route ~meth ~action = - Method_map.update - meth - (function - | None -> Some [ route, action ] - | Some xs -> Some ((route, action) :: xs)) - t +let add (t : t) ~route ~meth ~action = + Router.update t route ~f:(function + | None -> Method_map.singleton meth action + | Some m -> Method_map.add meth action m) ;; (** finds matching endpoint and returns it with the parsed list of parameters *) -let matching_endpoint endpoints meth uri = - let endpoints = get endpoints meth in - List.find_map endpoints ~f:(fun ep -> - uri |> Route.match_url (fst ep) |> Option.map (fun p -> ep, p)) +let matching_endpoint (endpoints : t) meth uri = + match Router.match_url endpoints uri with + | None -> None + | Some (a, params) -> + (match Method_map.find_opt meth a with + | None -> None + | Some h -> Some (h, params)) ;; module Env = struct - let key : Route.matches Context.key = - Context.Key.create ("path_params", Route.sexp_of_matches) + let key : Router.Params.t Context.key = + Context.Key.create ("path_params", Router.Params.sexp_of_t) ;; end -let splat req = Context.find_exn Env.key req.Request.env |> fun route -> route.Route.splat +let splat req = Context.find_exn Env.key req.Request.env |> Router.Params.unnamed (* not param_exn since if the endpoint was selected it's likely that the parameter is already there *) let param req param = - let { Route.params; _ } = Context.find_exn Env.key req.Request.env in - List.assoc param params + let params = Context.find_exn Env.key req.Request.env in + Router.Params.named params param ;; let m endpoints = let filter default req = match matching_endpoint endpoints req.Request.meth req.Request.target with | None -> default req - | Some (endpoint, params) -> + | Some (handler, params) -> let env_with_params = Context.add Env.key params req.Request.env in - (snd endpoint) { req with Request.env = env_with_params } + handler { req with Request.env = env_with_params } in Rock.Middleware.create ~name:"Router" ~filter ;; diff --git a/opium/src/middlewares/middleware_router.mli b/opium/src/middlewares/middleware_router.mli index e60d4e01..951ba88d 100644 --- a/opium/src/middlewares/middleware_router.mli +++ b/opium/src/middlewares/middleware_router.mli @@ -1,7 +1,7 @@ -type 'a t +type t -val m : Rock.Handler.t t -> Rock.Middleware.t -val empty : 'action t -val add : 'a t -> route:Route.t -> meth:Method.t -> action:'a -> 'a t +val m : t -> Rock.Middleware.t +val empty : t +val add : t -> route:Router.Route.t -> meth:Method.t -> action:Rock.Handler.t -> t val param : Request.t -> string -> string val splat : Request.t -> string list diff --git a/opium/src/opium.ml b/opium/src/opium.ml index 60032dd2..8dd6ae60 100644 --- a/opium/src/opium.ml +++ b/opium/src/opium.ml @@ -1,3 +1,7 @@ +module Private = struct + module Router = Router +end + module Context = Context module Headers = Headers module Cookie = Cookie @@ -8,8 +12,8 @@ module Body = Body module Request = Request module Response = Response module App = App -module Route = Route module Auth = Auth +module Route = Router.Route module Router = Middleware_router module Handler = struct diff --git a/opium/src/opium.mli b/opium/src/opium.mli index 0633d0b6..34c324e5 100644 --- a/opium/src/opium.mli +++ b/opium/src/opium.mli @@ -1,3 +1,9 @@ +module Private : sig + module Router : module type of struct + include Router + end +end + module Context = Context module Headers = Headers module Cookie = Cookie @@ -8,14 +14,14 @@ module Body = Body module Request = Request module Response = Response module App = App -module Route = Route module Auth = Auth +module Route = Router.Route module Router : sig - type 'action t + type t - val empty : 'action t - val add : 'a t -> route:Route.t -> meth:Method.t -> action:'a -> 'a t + val empty : t + val add : t -> route:Route.t -> meth:Method.t -> action:Rock.Handler.t -> t val param : Request.t -> string -> string val splat : Request.t -> string list end @@ -88,7 +94,7 @@ module Middleware : sig will redirect any URI containing two segments with the last segment containing "hello" to the handler defined in [Handler.hello_world]. *) - val router : Rock.Handler.t Router.t -> Rock.Middleware.t + val router : Router.t -> Rock.Middleware.t (** {3 [debugger]} *) diff --git a/opium/src/route.ml b/opium/src/route.ml deleted file mode 100644 index 09b965b2..00000000 --- a/opium/src/route.ml +++ /dev/null @@ -1,116 +0,0 @@ -open Import - -type path_segment = - | Match of string - | Param of string - | Splat - | FullSplat - | Slash - -type matches = - { params : (string * string) list - ; splat : string list - } - -let sexp_of_matches { params; splat } = - let splat' = Sexp_conv.sexp_of_list Sexp_conv.sexp_of_string splat in - let sexp_of_param (a, b) = Sexp_conv.sexp_of_list Sexp_conv.sexp_of_string [ a; b ] in - Sexp.List - [ List [ Atom "params"; Sexp_conv.sexp_of_list sexp_of_param params ] - ; List [ Atom "splat"; splat' ] - ] -;; - -type t = path_segment list - -let parse_param s = - if s = "/" - then Slash - else if s = "*" - then Splat - else if s = "**" - then FullSplat - else ( - try Scanf.sscanf s ":%s" (fun s -> Param s) with - | Scanf.Scan_failure _ -> Match s) -;; - -let of_list l = - let last_i = List.length l - 1 in - l - |> List.mapi ~f:(fun i s -> - match parse_param s with - | FullSplat when i <> last_i -> invalid_arg "** is only allowed at the end" - | x -> x) -;; - -let split_slash_delim = - let re = '/' |> Re.char |> Re.compile in - fun path -> - path - |> Re.split_full re - |> List.map ~f:(function - | `Text s -> `Text s - | `Delim _ -> `Delim) -;; - -let split_slash path = - path - |> split_slash_delim - |> List.map ~f:(function - | `Text s -> s - | `Delim -> "/") -;; - -let of_string path = path |> split_slash |> of_list - -let to_string l = - let r = - l - |> List.filter_map ~f:(function - | Match s -> Some s - | Param s -> Some (":" ^ s) - | Splat -> Some "*" - | FullSplat -> Some "**" - | Slash -> None) - |> String.concat ~sep:"/" - in - "/" ^ r -;; - -let rec match_url t url ({ params; splat } as matches) = - match t, url with - | [], [] -> Some { matches with splat = List.rev splat } - | [ FullSplat ], rest -> - let splat' = - List.filter_map - ~f:(function - | `Delim -> None - | `Text s -> Some (Uri.pct_decode s)) - rest - in - Some { matches with splat = splat' @ List.rev splat } - | FullSplat :: _, _ -> assert false (* splat can only be last *) - | Match x :: t, `Text y :: url when x = y -> match_url t url matches - | Slash :: t, `Delim :: url -> match_url t url matches - | Splat :: t, `Text s :: url -> - match_url t url { matches with splat = Uri.pct_decode s :: splat } - | Param name :: t, `Text p :: url -> - match_url t url { matches with params = (name, Uri.pct_decode p) :: params } - | Splat :: _, `Delim :: _ - | Param _ :: _, `Delim :: _ - | Match _ :: _, _ - | Slash :: _, _ - | _ :: _, [] - | [], _ :: _ -> None -;; - -let match_url t url = - let path = - match String.index_opt url '?' with - | None -> url - | Some i -> String.sub url ~pos:0 ~len:i - in - let path = path |> split_slash_delim in - match_url t path { params = []; splat = [] } -;; diff --git a/opium/src/route.mli b/opium/src/route.mli deleted file mode 100644 index f282e972..00000000 --- a/opium/src/route.mli +++ /dev/null @@ -1,21 +0,0 @@ -(** Expression that represent a target or multiple *) - -type t - -type matches = - { params : (string * string) list - ; splat : string list - } - -(** [sexp_of_t matches] converts the matches [matches] to an s-expression *) -val sexp_of_matches : matches -> Sexplib0.Sexp.t - -(** [of_string s] returns a route from its string representation [s]. *) -val of_string : string -> t - -(** [to_string t] returns a string representation of the route [t]. *) -val to_string : t -> string - -(** [match_url t url] return the matches of the url [url] for the route [t], or [None] if - the url does not match. *) -val match_url : t -> string -> matches option diff --git a/opium/src/router.ml b/opium/src/router.ml new file mode 100644 index 00000000..57317f65 --- /dev/null +++ b/opium/src/router.ml @@ -0,0 +1,339 @@ +open Import + +module Route = struct + open Printf + + type t = + | Nil + | Full_splat + | Literal of string * t + | Param of string option * t + + let equal = ( = ) + + let to_string t = + let rec loop acc = function + | Nil -> acc + | Full_splat -> "**" :: acc + | Literal (s, rest) -> loop (s :: acc) rest + | Param (s, rest) -> + let s = Option.value s ~default:"*" in + loop (s :: acc) rest + in + loop [] t |> List.rev |> String.concat ~sep:"/" + ;; + + let rec sexp_of_t (t : t) : Sexp.t = + match t with + | Nil -> Atom "Nil" + | Full_splat -> Atom "Full_splat" + | Literal (x, y) -> List [ Atom x; sexp_of_t y ] + | Param (x, y) -> + let x : Sexp.t = + match x with + | Some x -> Atom (":" ^ x) + | None -> Atom "*" + in + List [ x; sexp_of_t y ] + ;; + + exception E of string + + let rec parse_tokens params tokens = + match tokens with + | [ "**" ] -> Full_splat + | [] | [ "" ] -> Nil + | token :: tokens -> + if token = "" + then raise (E "Double '/' not allowed") + else if token = "*" + then Param (None, parse_tokens params tokens) + else if token = "**" + then raise (E (sprintf "double splat allowed only in the end")) + else if token.[0] = ':' + then ( + let name = + let len = String.length token in + if len > 1 + then String.sub token ~pos:1 ~len:(len - 1) + else raise (E "Named paramter is missing a name") + in + let params = + if List.mem name ~set:params + then raise (E (sprintf "duplicate parameter %S" name)) + else name :: params + in + Param (Some name, parse_tokens params tokens)) + else Literal (token, parse_tokens params tokens) + ;; + + let of_string s = + let tokens = String.split_on_char ~sep:'/' s in + match tokens with + | "" :: tokens -> parse_tokens [] tokens + | _ -> raise (E "route must start with /") + ;; + + let of_string_result s = + match of_string s with + | exception E s -> Error s + | s -> Ok s + ;; +end + +module Params = struct + type t = + { named : (string * string) list + ; unnamed : string list + ; full_splat : string option + } + + let make ~named ~unnamed ~full_splat = { named; unnamed; full_splat } + let all_named t = t.named + + let sexp_of_t { named; unnamed; full_splat } = + let open Sexp_conv in + Sexp.List + [ List + [ Atom "named" + ; sexp_of_list (sexp_of_pair sexp_of_string sexp_of_string) named + ] + ; List [ Atom "unnamed"; sexp_of_list sexp_of_string unnamed ] + ; List [ Atom "full_splat"; (sexp_of_option sexp_of_string) full_splat ] + ] + ;; + + let equal = ( = ) + let pp fmt t = Sexp.pp_hum fmt (sexp_of_t t) + let named t name = List.assoc name t.named + let unnamed t = t.unnamed + + let splat t = + match t.full_splat with + | None -> t.unnamed + | Some r -> t.unnamed @ String.split_on_char ~sep:'/' r + ;; + + let full_splat t = t.full_splat + let empty = { named = []; unnamed = []; full_splat = None } + + let create route captured (remainder : string option) = + let rec loop acc (route : Route.t) captured = + match route, captured with + | Full_splat, [] -> { acc with full_splat = remainder } + | Nil, [] -> acc + | Literal (_, route), _ -> loop acc route captured + | Param (None, route), p :: captured -> + let acc = { acc with unnamed = p :: acc.unnamed } in + loop acc route captured + | Param (Some name, route), p :: captured -> + let acc = { acc with named = (name, p) :: acc.named } in + loop acc route captured + | Full_splat, _ :: _ -> assert false + | Param (_, _), [] -> assert false + | Nil, _ :: _ -> assert false + in + let res = loop empty route captured in + { res with unnamed = List.rev res.unnamed } + ;; +end + +module Smap = Map.Make (String) + +type 'a t = + | Accept of ('a * Route.t) + | Node of + { data : ('a * Route.t) option + ; literal : 'a t Smap.t + ; param : 'a t option + } + +let sexp_of_smap f smap : Sexp.t = + List (Smap.bindings smap |> List.map ~f:(fun (k, v) -> Sexp.List [ Atom k; f v ])) +;; + +let rec sexp_of_t f t = + let open Sexp_conv in + match t with + | Accept (a, r) -> (sexp_of_pair f Route.sexp_of_t) (a, r) + | Node { data; literal; param } -> + Sexp.List + [ List [ Atom "data"; sexp_of_option (sexp_of_pair f Route.sexp_of_t) data ] + ; List [ Atom "literal"; sexp_of_smap (sexp_of_t f) literal ] + ; List [ Atom "param"; sexp_of_option (sexp_of_t f) param ] + ] +;; + +let empty_with data = Node { data; literal = Smap.empty; param = None } +let empty = empty_with None + +module Tokens : sig + type t + + val create : string -> t + val next : t -> (t * string) option + val remainder : t -> string option +end = struct + type t = + { start : int + ; s : string + } + + let create s = + if s = "" + then { s; start = 0 } + else if s.[0] = '/' + then { s; start = 1 } + else { s; start = 0 } + ;; + + let remainder t = + let len = String.length t.s in + if t.start >= len + then None + else if t.start = 0 + then Some t.s + else ( + let res = String.sub t.s ~pos:t.start ~len:(len - t.start) in + Some res) + ;; + + let next t = + let len = String.length t.s in + if t.start >= len + then None + else ( + match String.index_from_opt t.s t.start '/' with + | None -> + let res = + let len = len - t.start in + String.sub t.s ~pos:t.start ~len + in + Some ({ t with start = len }, res) + | Some j -> + let res = + let len = j - t.start in + String.sub t.s ~pos:t.start ~len + in + Some ({ t with start = j + 1 }, res)) + ;; +end + +let match_url t url = + let tokens = Tokens.create url in + let accept a route captured remainder = + let params = Params.create route (List.rev captured) remainder in + Some (a, params) + in + let rec loop t captured (tokens : Tokens.t) = + match t with + | Accept (a, route) -> + let remainder = Tokens.remainder tokens in + accept a route captured remainder + | Node t -> + (match Tokens.next tokens with + | None -> + (match t.data with + | None -> None + | Some (a, route) -> accept a route captured None) + | Some (tokens, s) -> + let param = + match t.param with + | None -> None + | Some node -> loop node (s :: captured) tokens + in + (match param with + | Some _ -> param + | None -> + (match Smap.find_opt s t.literal with + | None -> None + | Some node -> (loop [@tailcall]) node captured tokens))) + in + loop t [] tokens +;; + +let match_route t route = + let rec loop t (route : Route.t) = + match t with + | Accept (a, r) -> [ a, r ] + | Node t -> + (match route with + | Full_splat -> + let here = + match t.data with + | None -> [] + | Some (a, r) -> [ a, r ] + in + let by_param = by_param t.param route in + let by_literal = + (* TODO remove duplication with [Param] case *) + Smap.fold (fun _ node acc -> loop node route :: acc) t.literal [] |> List.concat + in + List.concat [ here; by_param; by_literal ] + | Nil -> + (match t.data with + | None -> [] + | Some (a, r) -> [ a, r ]) + | Literal (lit, route) -> + let by_param = by_param t.param route in + let by_literal = + match Smap.find_opt lit t.literal with + | None -> [] + | Some node -> loop node route + in + by_param @ by_literal + | Param (_, route) -> + let by_param = by_param t.param route in + let by_literal = + Smap.fold (fun _ node acc -> loop node route :: acc) t.literal [] + in + List.concat (by_param :: by_literal)) + and by_param param route = + match param with + | None -> [] + | Some node -> loop node route + in + match loop t route with + | [] -> Ok () + | routes -> Error routes +;; + +let add_no_check t orig_route a = + let rec loop t (route : Route.t) = + match t with + | Accept (_, _) -> assert false + | Node t -> + (match route with + | Full_splat -> Accept (a, orig_route) + | Nil -> empty_with (Some (a, orig_route)) + | Literal (lit, route) -> + let literal = + let node = Smap.find_opt lit t.literal |> Option.value ~default:empty in + Smap.add lit (loop node route) t.literal + in + Node { t with literal } + | Param (_, route) -> + let param = + let node = Option.value t.param ~default:empty in + loop node route + in + Node { t with param = Some param }) + in + loop t orig_route +;; + +let update t r ~f = + match match_route t r with + | Error [ (a, r') ] -> + if Route.equal r r' + then add_no_check t r (f (Some a)) + else failwith "duplicate routes" + | Ok () -> add_no_check t r (f None) + | Error ([] | _ :: _ :: _) -> failwith "duplicate routes" +;; + +let add t route a = + match match_route t route with + | Error _ -> failwith "duplicate routes" + | Ok () -> add_no_check t route a +;; diff --git a/opium/src/router.mli b/opium/src/router.mli new file mode 100644 index 00000000..63d2991c --- /dev/null +++ b/opium/src/router.mli @@ -0,0 +1,87 @@ +(** Trie based router. Allows for no ambiguities beteween routes. *) + +open Import + +module Route : sig + (** A route is defined by the following (pseaudo) bnf: + + {[ + ::= "/" "/**"? + + ::= + | "" + | "/" + | "/" + + ::= + | ":" [^/]+ + | "*" + + ::= [^/]+ + ]} + + Examples: + + - "/foo/bar" : route that only matches "/foo/bar" + - "/:foo/*" : route that matches a named parmeter "foo" and unnamed parameter + - "/foo/:bar/**" : A route that matches any route of the regex form /foo/[^/]+/.* *) + type t + + val of_string_result : string -> (t, string) result + val of_string : string -> t + val sexp_of_t : t -> Sexp.t + val to_string : t -> string +end + +module Params : sig + (** Parameters obtained after a route matches *) + type t + + (** Extract a single named parameter *) + val named : t -> string -> string + + (** only for testing *) + val all_named : t -> (string * string) list + + (** Only for testing *) + val make + : named:(string * string) list + -> unnamed:string list + -> full_splat:string option + -> t + + (** Etract all unnamed "*" parameters in order *) + val unnamed : t -> string list + + (** [full_splat t] returns the raw string matched by "**". *) + val full_splat : t -> string option + + (** [splat t] extracts unnamed + full_splat in a single list. This is present to match + the old routing behavior *) + val splat : t -> string list + + val sexp_of_t : t -> Sexp.t + val equal : t -> t -> bool + val pp : Format.formatter -> t -> unit +end + +(** Represents a router *) +type 'a t + +(** Empty router that matches no routes *) +val empty : 'a t + +(** [add router route h] Add [route] to [router] and attach [h] when [route] matches. + + It's not allowed to have more than a single route match a single path.*) +val add : 'a t -> Route.t -> 'a -> 'a t + +(** [update router route ~f] updates the value at [route]. [f None] is called if the route + wasn't added before. *) +val update : 'a t -> Route.t -> f:('a option -> 'a) -> 'a t + +(** [match_url router url] atempts to match [url] and returns the handler at the route and + parsed parameters. *) +val match_url : 'a t -> string -> ('a * Params.t) option + +val sexp_of_t : ('a -> Sexp.t) -> 'a t -> Sexp.t diff --git a/opium/test/dune b/opium/test/dune index f86261a4..e16c6ca3 100644 --- a/opium/test/dune +++ b/opium/test/dune @@ -1,4 +1,13 @@ (tests - (names middleware_allow_cors request response route cookie) - (libraries alcotest alcotest-lwt lwt opium) + (names middleware_allow_cors request response route) + (libraries alcotest alcotest-lwt lwt opium opium_testing) + (modules :standard \ opium_router_tests) (package opium)) + +(library + (name opium_tests) + (libraries opium) + (preprocess + (pps ppx_expect)) + (inline_tests) + (modules opium_router_tests)) diff --git a/opium/test/opium_router_tests.ml b/opium/test/opium_router_tests.ml new file mode 100644 index 00000000..075f03d2 --- /dev/null +++ b/opium/test/opium_router_tests.ml @@ -0,0 +1,202 @@ +open Sexplib0 +module Router = Opium.Private.Router +open Router + +let valid_route s = + match Route.of_string_result s with + | Error err -> print_endline ("[FAIL] invalid route " ^ err) + | Ok r -> Format.printf "[PASS] valid route:%a@." Sexp.pp_hum (Route.sexp_of_t r) +;; + +let%expect_test "nil route" = + valid_route "/"; + [%expect {| + [PASS] valid route:Nil |}] +;; + +let%expect_test "literal route" = + valid_route "/foo/bar"; + [%expect {| + [PASS] valid route:(foo (bar Nil)) |}] +;; + +let%expect_test "named parameters valid" = + valid_route "/foo/:param/:another"; + [%expect {| + [PASS] valid route:(foo (:param (:another Nil))) |}] +;; + +let%expect_test "unnamed parameter valid" = + valid_route "/foo/*"; + [%expect {| + [PASS] valid route:(foo (* Nil)) |}] +;; + +let%expect_test "param followed by literal" = + valid_route "/foo/*/bar/:param/bar"; + [%expect {| + [PASS] valid route:(foo (* (bar (:param (bar Nil))))) |}] +;; + +let%expect_test "duplicate paramters" = + valid_route "/foo/:bar/:bar/x"; + [%expect {| + [FAIL] invalid route duplicate parameter "bar" |}] +;; + +let%expect_test "splat in the middle is wrong" = + valid_route "/foo/**/foo"; + [%expect {| + [FAIL] invalid route double splat allowed only in the end |}] +;; + +let%expect_test "splat at the end" = + valid_route "/foo/**"; + [%expect {| + [PASS] valid route:(foo Full_splat) |}] +;; + +let test_match_url router url = + match Router.match_url router url with + | None -> print_endline "no match" + | Some (_, p) -> + Format.printf "matched with params: %a@." Sexp.pp_hum (Params.sexp_of_t p) +;; + +let%expect_test "dummy router matches nothing" = + test_match_url empty "/foo/123"; + [%expect {| + no match |}] +;; + +let%expect_test "we can add & match literal routes" = + let url = "/foo/bar" in + let route = Route.of_string url in + let router = add empty route () in + test_match_url router url; + [%expect {| + matched with params: ((named ()) (unnamed ()) (full_splat ())) |}] +;; + +let%expect_test "we can extract parameter after match" = + let route = Route.of_string "/foo/*/:bar" in + let router = add empty route () in + test_match_url router "/foo/100/baz"; + test_match_url router "/foo/100"; + test_match_url router "/foo/100/200/300"; + [%expect {| + matched with params: ((named ((bar baz))) (unnamed (100)) (full_splat ())) + no match + no match |}] +;; + +let of_routes routes = + List.fold_left + (fun router (route, data) -> add router (Route.of_string route) data) + empty + routes +;; + +let of_routes' routes = routes |> List.map (fun r -> r, ()) |> of_routes + +let%expect_test "ambiguity in routes" = + of_routes' [ "/foo/baz"; "/foo/bar"; "/foo/*" ] |> ignore; + [%expect.unreachable] + [@@expect.uncaught_exn + {| + (* CR expect_test_collector: This test expectation appears to contain a backtrace. + This is strongly discouraged as backtraces are fragile. + Please change this test to not include a backtrace. *) + + (Failure "duplicate routes") + Raised at Stdlib.failwith in file "stdlib.ml", line 29, characters 17-33 + Called from Stdlib__list.fold_left in file "list.ml", line 121, characters 24-34 + Called from Opium_tests__Opium_router_tests.(fun) in file "opium/test/opium_router_tests.ml", line 135, characters 2-49 + Called from Expect_test_collector.Make.Instance.exec in file "collector/expect_test_collector.ml", line 244, characters 12-19 |}] +;; + +let%expect_test "ambiguity in routes 2" = + of_routes' [ "/foo/*/bar"; "/foo/bar/*" ] |> ignore; + [%expect.unreachable] + [@@expect.uncaught_exn + {| + (* CR expect_test_collector: This test expectation appears to contain a backtrace. + This is strongly discouraged as backtraces are fragile. + Please change this test to not include a backtrace. *) + + (Failure "duplicate routes") + Raised at Stdlib.failwith in file "stdlib.ml", line 29, characters 17-33 + Called from Stdlib__list.fold_left in file "list.ml", line 121, characters 24-34 + Called from Opium_tests__Opium_router_tests.(fun) in file "opium/test/opium_router_tests.ml", line 151, characters 2-43 + Called from Expect_test_collector.Make.Instance.exec in file "collector/expect_test_collector.ml", line 244, characters 12-19 |}] +;; + +let test_match router url expected_value = + match match_url router url with + | Some (s, _) -> assert (s = expected_value) + | None -> + Format.printf "%a@." Sexp.pp_hum (Router.sexp_of_t Sexp_conv.sexp_of_string router) +;; + +let%expect_test "nodes are matched correctly" = + let router = of_routes [ "/foo/bar", "Wrong"; "/foo/baz", "Right" ] in + let test = test_match router in + test "/foo/bar" "Wrong"; + test "/foo/baz" "Right"; + [%expect + {| |}] +;; + +let%expect_test "full splat node matches" = + let router = of_routes' [ "/foo/**" ] in + let test = test_match_url router in + test "/foo/bar"; + test "/foo/bar/foo"; + test "/foo/"; + [%expect {| + matched with params: ((named ()) (unnamed ()) (full_splat (bar))) + matched with params: ((named ()) (unnamed ()) (full_splat (bar/foo))) + matched with params: ((named ()) (unnamed ()) (full_splat ())) |}] +;; + +let%expect_test "full splat + collision checking" = + ignore (of_routes' [ "/foo/**"; "/*/bar" ]); + [%expect.unreachable] + [@@expect.uncaught_exn + {| + (* CR expect_test_collector: This test expectation appears to contain a backtrace. + This is strongly discouraged as backtraces are fragile. + Please change this test to not include a backtrace. *) + + (Failure "duplicate routes") + Raised at Stdlib.failwith in file "stdlib.ml", line 29, characters 17-33 + Called from Stdlib__list.fold_left in file "list.ml", line 121, characters 24-34 + Called from Opium_tests__Opium_router_tests.(fun) in file "opium/test/opium_router_tests.ml", line 211, characters 9-45 + Called from Expect_test_collector.Make.Instance.exec in file "collector/expect_test_collector.ml", line 244, characters 12-19 |}] +;; + +let%expect_test "two parameters" = + let router = of_routes' [ "/test/:format/:name/:baz" ] in + let test = test_match_url router in + test "/test/json/bar/blah"; + [%expect {| + matched with params: ((named ((baz blah) (name bar) (format json))) + (unnamed ()) (full_splat ())) |}] +;; + +let%expect_test "full splat" = + let router = of_routes' [ "/**" ] in + let test = test_match_url router in + test "/test"; + test "/test/"; + test "/"; + test ""; + test "/user/123/foo/bar"; + [%expect{| + matched with params: ((named ()) (unnamed ()) (full_splat (test))) + matched with params: ((named ()) (unnamed ()) (full_splat (test/))) + matched with params: ((named ()) (unnamed ()) (full_splat ())) + matched with params: ((named ()) (unnamed ()) (full_splat ())) + matched with params: ((named ()) (unnamed ()) + (full_splat (user/123/foo/bar))) |}] +;; diff --git a/opium/test/route.ml b/opium/test/route.ml index dc691502..bd69d120 100644 --- a/opium/test/route.ml +++ b/opium/test/route.ml @@ -1,22 +1,50 @@ open Sexplib0 -module Route = Opium.Route +module Router = Opium.Private.Router + +module Route = struct + include Opium.Route + + module Matches = struct + type t = + { params : (string * string) list + ; splat : string list + } + + let equal = ( = ) + + let pp fmt { params; splat } = + let sexp = + Router.Params.make ~named:params ~unnamed:splat ~full_splat:None + |> Router.Params.sexp_of_t + in + Sexp.pp_hum fmt sexp + ;; + + let of_params params = + let splat = Router.Params.unnamed params in + let params = List.rev (Router.Params.all_named params) in + { params; splat } + ;; + end + + let match_url r u = + let router = Router.add Router.empty r () in + match Router.match_url router u with + | None -> None + | Some ((), params) -> Some (Matches.of_params params) + ;; + + include Matches +end let slist t = Alcotest.slist t compare let params = slist Alcotest.(pair string string) - -let matches_t : Route.matches Alcotest.testable = - (module struct - type t = Route.matches - - let equal r1 r2 = r1.Route.splat = r2.Route.splat && r1.Route.params = r2.Route.params - let pp f t = Sexp.pp_hum f (Route.sexp_of_matches t) - end) -;; +let matches_t : Route.Matches.t Alcotest.testable = (module Route.Matches) let match_get_params route url = match Route.match_url route url with | None -> None - | Some { Route.params; _ } -> Some params + | Some p -> Some p.params ;; let string_of_match = function