diff --git a/src/Bolero/Router.fs b/src/Bolero/Router.fs index 7dacd871..b632139f 100644 --- a/src/Bolero/Router.fs +++ b/src/Bolero/Router.fs @@ -28,7 +28,9 @@ open System.Diagnostics.CodeAnalysis open System.Net open System.Runtime.CompilerServices open System.Runtime.InteropServices +open System.Text open FSharp.Reflection +open Microsoft.FSharp.Core.CompilerServices /// A router that binds page navigation with Elmish. /// The Elmish model type. @@ -82,16 +84,23 @@ type Router<'ep, 'model, 'msg> = /// Declare how an F# union case matches to a URI. /// Routing [] -type EndPointAttribute - /// Declare how an F# union case matches to a URI. - /// The endpoint URI path. - (endpoint: string) = +type EndPointAttribute(path, query) = inherit Attribute() - let endpoint = endpoint.Trim('/').Split('/') + /// Declare how an F# union case matches to a URI. + /// The endpoint URI path and query. + new (endpoint: string) = + let path, query = + match endpoint.IndexOf('?') with + | -1 -> endpoint, "" + | n -> endpoint[..n-1], endpoint[n+1..] + EndPointAttribute(path.Trim('/').Split('/'),query) /// The path that this endpoint recognizes. - member this.Path = endpoint + member this.Path = path + + /// The query string that this endpoint recognizes. + member this.Query = query /// /// Declare that the given field of an F# union case matches the entire remainder of the URL path. @@ -169,13 +178,18 @@ type PageModel<'T> = module private RouterImpl = open System.Text.RegularExpressions - type ArraySegment<'T> with - member this.Item with get i = this.Array[this.Offset + i] + type SingleParser = string -> option + type SingleWriter = obj -> option + type SingleSerializer = + { + parse: SingleParser + write: SingleWriter + } type SegmentParserResult = option> - type SegmentParser = list -> SegmentParserResult - type SegmentWriter = obj -> list - type Segment = + type SegmentParser = list -> Map -> SegmentParserResult + type SegmentWriter = obj -> list * Map + type SegmentSerializer = { parse: SegmentParser write: SegmentWriter @@ -190,64 +204,110 @@ module private RouterImpl = else None - let inline defaultBaseTypeParser<'T when 'T : (static member TryParse : string * byref<'T> -> bool)> = function - | [] -> None - | x :: rest -> - match tryParseBaseType<'T> x with - | Some x -> Some (box x, rest) - | None -> None + let inline baseTypeSingleSerializer<'T when 'T : (static member TryParse : string * byref<'T> -> bool)> () : SingleSerializer = + { + parse = tryParseBaseType<'T> + write = string >> Some + } - let inline baseTypeSegment<'T when 'T : (static member TryParse : string * byref<'T> -> bool)> () = + let singleSegmentSerializer (s: SingleSerializer) : SegmentSerializer = { - parse = defaultBaseTypeParser<'T> - write = fun x -> [string x] + parse = fun path _ -> + match path with + | [] -> None + | x :: rest -> + match s.parse x with + | Some x -> Some (x, rest) + | None -> None + write = fun x -> Option.toList (s.write x), Map.empty } - let baseTypes : IDictionary = dict [ + let baseTypeSingleSerializers : IDictionary = dict [ typeof, { - parse = function - | [] -> None - | x :: rest -> Some (box (WebUtility.UrlDecode x), rest) - write = fun x -> [WebUtility.UrlEncode(unbox x)] + parse = fun x -> Some (box (WebUtility.UrlDecode x)) + write = fun x -> Some (WebUtility.UrlEncode (unbox x)) } typeof, { - parse = defaultBaseTypeParser + parse = tryParseBaseType // `string true` returns capitalized "True", but we want lowercase "true". - write = fun x -> [(if unbox x then "true" else "false")] + write = fun x -> Some (if unbox x then "true" else "false") } - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() - typeof, baseTypeSegment() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() + typeof, baseTypeSingleSerializer() ] - let sequenceSegment getSegment (ty: Type) revAndConvert toListAndLength : Segment = + let baseTypeSegmentSerializers : IDictionary = dict [ + for KeyValue(k, s) in baseTypeSingleSerializers do + k, singleSegmentSerializer s + ] + + let getSingleSerializer (ty: Type) : SingleSerializer * obj voption = + match baseTypeSingleSerializers.TryGetValue(ty) with + | true, s -> s, ValueNone + | false, _ -> + if ty.IsGenericType && + (let gen = ty.GetGenericTypeDefinition() + gen = typedefof> || gen = typedefof>) + then + match baseTypeSingleSerializers.TryGetValue(ty.GetGenericArguments()[0]) with + | true, s -> + let cases = FSharpType.GetUnionCases(ty) + let noneCase = cases[0] + let someCase = cases[1] + let someCtor = FSharpValue.PreComputeUnionConstructor(someCase) + let someDector = FSharpValue.PreComputeUnionReader(someCase) + let none = FSharpValue.MakeUnion(noneCase, Array.empty) + let getTag = FSharpValue.PreComputeUnionTagReader(ty) + { + parse = s.parse >> Option.map (fun x -> someCtor [|x|]) + write = fun x -> + if getTag x = 0 then + None + else + s.write (someDector x).[0] + }, ValueSome none + | false, _ -> fail (InvalidRouterKind.UnsupportedType ty) + else + fail (InvalidRouterKind.UnsupportedType ty) + + let merge (map1: Map<'k, 'v>) (map2: Map<'k, 'v>) = + Map.foldBack Map.add map1 map2 + + let sequenceSegment getSegment (ty: Type) revAndConvert toListAndLength : SegmentSerializer = let itemSegment = getSegment ty - let rec parse acc remainingLength fragments = + let rec parse acc remainingLength fragments query = if remainingLength = 0 then Some (revAndConvert acc, fragments) else - match itemSegment.parse fragments with + match itemSegment.parse fragments query with | None -> None | Some (x, rest) -> - parse (x :: acc) (remainingLength - 1) rest + parse (x :: acc) (remainingLength - 1) rest query { - parse = function + parse = fun path query -> + match path with | x :: rest -> match Int32.TryParse(x) with - | true, length -> parse [] length rest + | true, length -> parse [] length rest query | false, _ -> None | _ -> None write = fun x -> let list, (length: int) = toListAndLength x - string length :: List.collect itemSegment.write list + let path, query = + (Map.empty, list) + ||> List.mapFold (fun query item -> + let segments, itemQuery = itemSegment.write item + segments, merge itemQuery query) + (string length :: List.concat path), query } let [] FLAGS_STATIC = @@ -263,12 +323,12 @@ module private RouterImpl = let arrayLengthAndBox<'T> (a: array<'T>) : list * int = [for x in a -> box x], a.Length - let arraySegment getSegment ty : Segment = + let arraySegment getSegment ty : SegmentSerializer = let arrayRevAndUnbox = - typeof.DeclaringType.GetMethod("arrayRevAndUnbox", FLAGS_STATIC) + typeof.DeclaringType.GetMethod("arrayRevAndUnbox", FLAGS_STATIC) .MakeGenericMethod([|ty|]) let arrayLengthAndBox = - typeof.DeclaringType.GetMethod("arrayLengthAndBox", FLAGS_STATIC) + typeof.DeclaringType.GetMethod("arrayLengthAndBox", FLAGS_STATIC) .MakeGenericMethod([|ty|]) sequenceSegment getSegment ty (fun l -> arrayRevAndUnbox.Invoke(null, [|l|])) @@ -280,12 +340,12 @@ module private RouterImpl = let listLengthAndBox<'T> (l: list<'T>) : list * int = List.mapFold (fun l e -> box e, l + 1) 0 l - let listSegment getSegment ty : Segment = + let listSegment getSegment ty : SegmentSerializer = let listRevAndUnbox = - typeof.DeclaringType.GetMethod("listRevAndUnbox", FLAGS_STATIC) + typeof.DeclaringType.GetMethod("listRevAndUnbox", FLAGS_STATIC) .MakeGenericMethod([|ty|]) let listLengthAndBox = - typeof.DeclaringType.GetMethod("listLengthAndBox", FLAGS_STATIC) + typeof.DeclaringType.GetMethod("listLengthAndBox", FLAGS_STATIC) .MakeGenericMethod([|ty|]) sequenceSegment getSegment ty (fun l -> listRevAndUnbox.Invoke(null, [|l|])) @@ -308,13 +368,13 @@ module private RouterImpl = | _ -> false /// A {parameter} path segment. - type Parameter = + type SegmentParameter = { /// A parameter can be common among multiple union cases. /// `index` lists these cases, and for each of them, its total number of fields and the index of the field for this segment. index: list ``type``: Type - segment: Segment + segment: SegmentSerializer modifier: ParameterModifier /// Note that several cases can have the same parameter with different names. /// In this case, the name field is taken from the first declared case. @@ -324,7 +384,16 @@ module private RouterImpl = /// Intermediate representation of a path segment. type UnionParserSegment = | Constant of string - | Parameter of Parameter + | Parameter of SegmentParameter + + type QueryParameter = + { + index: int + serializer: SingleSerializer + optionalDefaultValue: obj voption + name: string + propName: string + } type UnionCase = { @@ -332,6 +401,7 @@ module private RouterImpl = ctor: obj[] -> obj argCount: int segments: UnionParserSegment list + query: QueryParameter list } /// The parser for a union type at a given point in the path. @@ -340,17 +410,17 @@ module private RouterImpl = /// All recognized "/constant" segments, associated with the parser for the rest of the path. constants: IDictionary /// The recognized "/{parameter}" segment, if any. - parameter: option + parameter: option /// The union case that parses correctly if the path ends here, if any. finalize: option } - let parseEndPointCasePath (case: UnionCaseInfo) : list = - case.GetCustomAttributes() + let parseEndPointCasePathAndQuery (case: UnionCaseInfo) : list * string = + case.GetCustomAttributes(typeof) |> Array.tryPick (function - | :? EndPointAttribute as e -> Some (List.ofSeq e.Path) + | :? EndPointAttribute as e -> Some (List.ofSeq e.Path, e.Query) | _ -> None) - |> Option.defaultWith (fun () -> [case.Name]) + |> Option.defaultWith (fun () -> [case.Name], "") let isConstantFragment (s: string) = not (s.Contains("{")) @@ -397,7 +467,8 @@ module private RouterImpl = else fail (InvalidRouterKind.InvalidRestType case) - let fragmentParameterRE = Regex(@"^\{([?*]?)([a-zA-Z0-9_]+)\}$", RegexOptions.Compiled) + let fragmentParameterRE = Regex(@"^\{([*]?)([a-zA-Z0-9_]+)\}$", RegexOptions.Compiled) + let queryParameterRE = Regex(@"^\{([a-zA-Z0-9_]+)\}$", RegexOptions.Compiled) let isPageModel (ty: Type) = ty.IsGenericType && ty.GetGenericTypeDefinition() = typedefof> @@ -432,14 +503,44 @@ module private RouterImpl = vals[i] <- model ctor vals + let parseQueryParameters (case: UnionCaseInfo) (query: string) : QueryParameter list = + let fields = case.GetFields() + query.Split('&', StringSplitOptions.RemoveEmptyEntries) + |> Seq.map (fun q -> + let paramName, propName = + match q.IndexOf('=') with + | -1 -> + let name = queryParameterRE.Match(q).Groups[1].Value + name, name + | n -> + let name = q[..n-1] + name, queryParameterRE.Match(q[n+1..]).Groups[1].Value + let index = + fields + |> Array.tryFindIndex (fun f -> f.Name = propName) + |> Option.defaultWith(fun () -> fail (InvalidRouterKind.UnknownField(case, propName))) + let prop = fields[index] + let serializer, optionalDefaultValue = getSingleSerializer prop.PropertyType + { + index = index + serializer = serializer + optionalDefaultValue = optionalDefaultValue + name = paramName + propName = propName + }) + |> List.ofSeq + let parseEndPointCase getSegment (defaultPageModel: obj -> unit) (case: UnionCaseInfo) = let ctor = getCtor defaultPageModel case let fields = case.GetFields() + let path, query = parseEndPointCasePathAndQuery case + let query = parseQueryParameters case query let defaultFrags() = fields |> Array.mapi (fun i p -> let ty = p.PropertyType - if isPageModel ty then None else + if isPageModel ty then None + elif query |> List.exists (fun q -> q.propName = p.Name) then None else Some <| Parameter { index = [case, fields.Length, i] ``type`` = ty @@ -449,20 +550,20 @@ module private RouterImpl = }) |> Array.choose id |> List.ofSeq - match parseEndPointCasePath case with + match path with // EndPoint "/" - | [] -> { info = case; ctor = ctor; argCount = fields.Length; segments = defaultFrags() } + | [] -> { info = case; ctor = ctor; argCount = fields.Length; segments = defaultFrags(); query = query } // EndPoint "/const" | [root] when isConstantFragment root -> - { info = case; ctor = ctor; argCount = fields.Length; segments = Constant root :: defaultFrags() } + { info = case; ctor = ctor; argCount = fields.Length; segments = Constant root :: defaultFrags(); query = query } // EndPoint | frags -> let unboundFields = fields - |> Array.choose (fun f -> if isPageModel f.PropertyType then None else Some f.Name) + |> Array.choose (fun f -> if isPageModel f.PropertyType || query |> List.exists (fun q -> q.propName = f.Name) then None else Some f.Name) |> HashSet let fragCount = frags.Length - let res = + let segments = frags |> List.mapi (fun fragIx frag -> if isConstantFragment frag then @@ -496,7 +597,7 @@ module private RouterImpl = ) if unboundFields.Count > 0 then fail (InvalidRouterKind.MissingField(case, Seq.head unboundFields)) - { info = case; ctor = ctor; argCount = fields.Length; segments = res } + { info = case; ctor = ctor; argCount = fields.Length; segments = segments; query = query } let rec mergeEndPointCaseFragments (cases: seq) : UnionParser = let constants = Dictionary() @@ -512,7 +613,7 @@ module private RouterImpl = constants[s] <- { case with segments = rest } :: existing | Parameter param :: rest -> match parameter with - | Some (case', param', ps) -> + | Some (case', param': SegmentParameter, ps) -> if param.``type`` <> param'.``type`` then fail (InvalidRouterKind.ParameterTypeMismatch(case', param'.name, case.info, param.name)) if param.modifier <> param'.modifier then @@ -540,23 +641,41 @@ module private RouterImpl = let parser = mergeEndPointCaseFragments cases fun l -> let d = Dictionary() - let rec run (parser: UnionParser) l = + let rec run (parser: UnionParser) segments query = let finalize rest = - parser.finalize |> Option.map (fun case -> + parser.finalize |> Option.bind (fun case -> let args = match d.TryGetValue(case.info) with | true, args -> args | false, _ -> Array.zeroCreate case.argCount - (case.ctor args, rest)) + let allQueryParamsAreHere = + case.query + |> List.forall (fun p -> + match Map.tryFind p.name query with + | None -> + match p.optionalDefaultValue with + | ValueSome def -> + args[p.index] <- def + true + | ValueNone -> false + | Some v -> + match p.serializer.parse v with + | Some x -> + args[p.index] <- x + true + | None -> false) + if allQueryParamsAreHere then + Some (case.ctor args, rest) + else None) let mutable constant = Unchecked.defaultof<_> - match l with + match segments with | s :: rest when parser.constants.TryGetValue(s, &constant) -> - run constant rest - | l -> + run constant rest query + | segments -> parser.parameter |> Option.bind (function | { modifier = Basic } as param, nextParser -> - match param.segment.parse l with + match param.segment.parse segments query with | None -> None | Some (o, rest) -> for case, fieldCount, i in param.index do @@ -568,11 +687,11 @@ module private RouterImpl = d[case] <- a a a[i] <- o - run nextParser rest + run nextParser rest query | { modifier = Rest(restBuild, _) } as param, nextParser -> let restValues = ResizeArray() - let rec parse l = - match param.segment.parse l, l with + let rec parse segments = + match param.segment.parse segments query, segments with | None, [] -> for case, fieldCount, i in param.index do let a = @@ -583,25 +702,25 @@ module private RouterImpl = d[case] <- a a a[i] <- restBuild restValues - run nextParser [] + run nextParser [] query | None, _::_ -> None | Some (o, rest), _ -> restValues.Add(o) parse rest - parse l + parse segments ) - |> Option.orElseWith (fun () -> finalize l) + |> Option.orElseWith (fun () -> finalize segments) run parser l let parseConsecutiveTypes getSegment (tys: Type[]) (ctor: obj[] -> obj) : SegmentParser = let fields = Array.map getSegment tys - fun (fragments: list) -> + fun (fragments: list) query -> let args = Array.zeroCreate fields.Length let rec go i fragments = if i = fields.Length then Some (ctor args, fragments) else - match fields[i].parse fragments with + match fields[i].parse fragments query with | None -> None | Some (x, rest) -> args[i] <- x @@ -611,8 +730,14 @@ module private RouterImpl = let writeConsecutiveTypes getSegment (tys: Type[]) (dector: obj -> obj[]) : SegmentWriter = let fields = tys |> Array.map (fun t -> (getSegment t).write) fun (r: obj) -> - Array.map2 (<|) fields (dector r) - |> List.concat + let mutable segments = ListCollector() + let query = + (Map.empty, fields, dector r) + |||> Array.fold2 (fun query field item -> + let itemSegments, itemQuery = field item + segments.AddMany(itemSegments) + merge itemQuery query) + segments.Close(), query let caseDector (case: UnionCaseInfo) : obj -> obj[] = FSharpValue.PreComputeUnionReader(case, true) @@ -621,17 +746,36 @@ module private RouterImpl = let dector = caseDector case.info fun o -> let vals = dector o - case.segments |> List.collect (function - | Constant s -> [s] - | Parameter({ modifier = Basic } as param) -> - let _, _, i = param.index |> List.find (fun (case', _, _) -> case' = case.info) - param.segment.write vals[i] - | Parameter({ modifier = Rest(_, decons) } as param) -> - let _, _, i = param.index |> List.find (fun (case', _, _) -> case' = case.info) - [ for x in decons vals[i] do yield! param.segment.write x ] - ) + let mutable segments = ListCollector() + let query = + case.query + |> Seq.choose (fun param -> + param.serializer.write vals[param.index] + |> Option.map (fun s -> param.name, s)) + |> Map + let query = + (query, case.segments) + ||> List.fold (fun query item -> + match item with + | Constant s -> + segments.Add(s) + query + | Parameter({ modifier = Basic } as param) -> + let _, _, i = param.index |> List.find (fun (case', _, _) -> case' = case.info) + let itemSegments, itemQuery = param.segment.write vals[i] + segments.AddMany(itemSegments) + merge itemQuery query + | Parameter({ modifier = Rest(_, decons) } as param) -> + let _, _, i = param.index |> List.find (fun (case', _, _) -> case' = case.info) + (query, decons vals[i]) + ||> Seq.fold (fun query x -> + let itemSegments, itemQuery = param.segment.write x + segments.AddMany(itemSegments) + merge itemQuery query) + ) + segments.Close(), query - let unionSegment (getSegment: Type -> Segment) (defaultPageModel: obj -> unit) (ty: Type) : Segment = + let unionSegment (getSegment: Type -> SegmentSerializer) (defaultPageModel: obj -> unit) (ty: Type) : SegmentSerializer = let cases = FSharpType.GetUnionCases(ty, true) |> Array.map (parseEndPointCase getSegment defaultPageModel) @@ -660,7 +804,7 @@ module private RouterImpl = write = writeConsecutiveTypes getSegment tys dector } - let rec getSegment (cache: Dictionary) (defaultPageModel: obj -> unit) (ty: Type) : Segment = + let rec getSegment (cache: Dictionary) (defaultPageModel: obj -> unit) (ty: Type) : SegmentSerializer = match cache.TryGetValue(ty) with | true, x -> unbox x | false, _ -> @@ -687,6 +831,20 @@ module private RouterImpl = cache[ty] <- segment.Value segment.Value + let splitPathAndQuery (pathAndQuery: string) : string list * Map = + match pathAndQuery.IndexOf('?') with + | -1 -> pathAndQuery.Split('/') |> List.ofArray, Map.empty + | n -> + let path = pathAndQuery[..n-1].Split('/') |> List.ofArray + let query = + pathAndQuery[n+1..].Split('&') + |> Seq.map (fun s -> + match s.IndexOf('=') with + | -1 -> s, "" + | n -> s[..n-1], s[n+1..]) + |> Map + path, query + /// Functions for building Routers that bind page navigation with Elmish. /// Routing module Router = @@ -707,18 +865,28 @@ module Router = (makeMessage: 'ep -> 'msg) (getEndPoint: 'model -> 'ep) (defaultPageModel: 'ep -> unit) = let ty = typeof<'ep> let cache = Dictionary() - for KeyValue(k, v) in baseTypes do cache.Add(k, v) + for KeyValue(k, v) in baseTypeSegmentSerializers do cache.Add(k, v) let frag = getSegment cache (unbox >> defaultPageModel) ty { getEndPoint = getEndPoint getRoute = fun ep -> - box ep - |> frag.write - |> String.concat "/" + let segments, query = frag.write (box ep) + let path = String.concat "/" segments + if Map.isEmpty query then + path + else + let sb = StringBuilder(path) + query + |> Seq.iteri (fun i (KeyValue(k, v)) -> + sb.Append(if i = 0 then '?' else '&') + .Append(k) + .Append('=') + .Append(v) + |> ignore) + sb.ToString() setRoute = fun path -> - path.Split('/') - |> List.ofArray - |> frag.parse + splitPathAndQuery path + ||> frag.parse |> Option.bind (function | x, [] -> Some (unbox<'ep> x |> makeMessage) | _ -> None) diff --git a/tests/Client/Main.fs b/tests/Client/Main.fs index 12acbe9e..348dde5d 100644 --- a/tests/Client/Main.fs +++ b/tests/Client/Main.fs @@ -33,7 +33,7 @@ type Page = | [] Form | [] Collection | [] Item of key: int * model: PageModel - | [] Lazy + | [] Lazy of value: int * value2: string option | [] Virtual type Item = @@ -102,7 +102,7 @@ let initModel _ = } let defaultPageModel = function - | Form | Collection | Lazy | Virtual -> () + | Form | Collection | Lazy _ | Virtual -> () | Item (_, m) -> Router.definePageModel m 10 let router = Router.inferWithModel SetPage (fun m -> m.page) defaultPageModel @@ -271,9 +271,10 @@ type ViewItemPage() = } } -let viewLazy model dispatch = +let viewLazy i model dispatch = let lazyViewFunction = (fun m -> text $"Lazy values: ({m.value},{m.nonEqVal}), re-render random number check: {System.Random().Next()}") div { + p { string i } pre { text """ let viewLazy model dispatch = @@ -334,7 +335,9 @@ let view js model dispatch = text " " navLink NavLinkMatch.Prefix { router.HRef Collection; "Collection" } text " " - navLink NavLinkMatch.Prefix { router.HRef Lazy; "Lazy" } + navLink NavLinkMatch.Prefix { attr.href (router.Link (Lazy (123, Some "abc"))); "Lazy" } + text " " + navLink NavLinkMatch.Prefix { attr.href (router.Link (Lazy (123, None))); "Lazy" } text " " navLink NavLinkMatch.All { router.HRef Virtual; "Virtual" } } @@ -342,7 +345,7 @@ let view js model dispatch = | Form -> viewForm js model dispatch | Collection -> viewCollection model dispatch | Item (k, m) -> ecomp (k, model.items.[k], m.Model) dispatch { attr.empty() } - | Lazy -> viewLazy model dispatch + | Lazy (x, y) -> viewLazy (x, y) model dispatch | Virtual -> viewVirtual model dispatch } diff --git a/tests/Unit.Client/Routing.fs b/tests/Unit.Client/Routing.fs index b53ff434..60c86f3b 100644 --- a/tests/Unit.Client/Routing.fs +++ b/tests/Unit.Client/Routing.fs @@ -47,6 +47,7 @@ type Page = | [] WithRestArray of rest: (int * string)[] | [] WithModel of PageModel | [] WithModelAndArgs of arg: int * PageModel + | [] WithQuery of arg: int * n: int * implicit: int * optional: int option * voptional: int voption and InnerPage = | [] InnerHome @@ -113,6 +114,7 @@ let rec pageClass = function | WithRestArray a -> $"""withrestarray-{a |> Seq.map (fun (i, s) -> $"{i}-{s}") |> String.concat "-"}""" | WithModel _ -> "withmodel" | WithModelAndArgs (a, _) -> $"withmodelargs-{a}" + | WithQuery(a, b, c, d, e) -> $"withquery-{a}-{b}-{c}-{d}-{e}" let innerlinks = [ @@ -164,6 +166,8 @@ let baseLinks = "/with-rest-array/1/foo/2/bar", WithRestArray [|(1, "foo"); (2, "bar")|] "/with-model", WithModel { Model = Unchecked.defaultof<_> } "/with-model-args/42", WithModelAndArgs(42, { Model = Unchecked.defaultof<_> }) + "/with-query/42?implicit=2&named=1&optional=3", WithQuery(42, 1, 2, Some 3, ValueNone) + "/with-query/42?implicit=5&named=4&voptional=3", WithQuery(42, 4, 5, None, ValueSome 3) ] for link, page in innerlinks do yield "/with-union" + link, WithUnion page