diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md
index a7c81c54..3bb1daf7 100644
--- a/DOCUMENTATION.md
+++ b/DOCUMENTATION.md
@@ -871,7 +871,37 @@ let webApp =
route "/bar" >=> text "Bar 2" ]) ])
```
-**Note:** For both `subRoute` and `subRouteCi` if you wish to have a route that represents a default e.g. `/api/v1` (from the above example) then you need to specify the route as `route ""` not `route "/"` this will not match, as `api/v1/` is a fundamentally different route according to the HTTP specification.
+Please note that only the path specified for `subRouteCi` is case insensitive. Nested routes after `subRouteCi` will be evaluated as per definition of each individual route.
+
+**Note:** If you wish to have a default route for any `subRoute` handler (e.g. `/api/v1` from the above example) then you need to specify the route as `route ""` and not as `route "/"`, because `/api/v1/` is a fundamentally different than `/api/v1` according to the HTTP specification.
+
+#### subRoutef
+
+The `subRoutef` http handler is a combination of the `routef` and the `subRoute` http handler:
+
+```fsharp
+let app =
+ GET >=> choose [
+ route "/" >=> text "index"
+ route "/foo" >=> text "bar"
+
+ subRoutef "/%s/api" (fun lang ->
+ requiresAuthentication (challenge "Cookie") >=>
+ choose [
+ route "/blah" >=> text "blah"
+ routef "/%s" (fun n -> text (sprintf "Hello %s! Lang: %s" n lang))
+ ])
+ setStatusCode 404 >=> text "Not found" ]
+```
+
+This can be useful when an application has dynamic parameters at the beginning of each route (e.g. language parameter):
+
+```
+https://example.org/en/users/John
+https://example.org/de/users/Ryan
+https://example.org/fr/users/Nicky
+...
+```
#### routePorts
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 76860f86..d9931a73 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,12 @@
Release Notes
=============
+## Development
+
+#### New features
+
+- Added `subRoutef` http handler (see [subRoutef](https://github.com/giraffe-fsharp/Giraffe/blob/master/DOCUMENTATION.md#subroutef))
+
## 1.0.0
First RTM release of Giraffe.
diff --git a/src/Giraffe/Routing.fs b/src/Giraffe/Routing.fs
index 77ce8745..13a44acf 100644
--- a/src/Giraffe/Routing.fs
+++ b/src/Giraffe/Routing.fs
@@ -212,4 +212,44 @@ let subRoute (path : string) (handler : HttpHandler) : HttpHandler =
/// A Giraffe `HttpHandler` function which can be composed into a bigger web application.
let subRouteCi (path : string) (handler : HttpHandler) : HttpHandler =
routeStartsWithCi path >=>
- handlerWithRootedPath path handler
\ No newline at end of file
+ handlerWithRootedPath path handler
+
+/// ** Description **
+/// Filters an incoming HTTP request based on a part of the request path (case sensitive).
+/// If the sub route matches the incoming HTTP request then the arguments from the `PrintfFormat<...>` will be automatically resolved and passed into the supplied `routeHandler`.
+///
+/// ** Supported format chars **
+/// - `%b`: `bool`
+/// - `%c`: `char`
+/// - `%s`: `string`
+/// - `%i`: `int`
+/// - `%d`: `int64`
+/// - `%f`: `float`/`double`
+/// - `%O`: `Guid`
+///
+/// Subsequent routing handlers inside the given `handler` function should omit the already validated path.
+///
+/// ** Parameters **
+/// - `path`: A format string representing the expected request sub path.
+/// - `routeHandler`: A function which accepts a tuple `'T` of the parsed arguments and returns a `HttpHandler` function which will subsequently deal with the request.
+///
+/// ** Output **
+/// A Giraffe `HttpHandler` function which can be composed into a bigger web application.
+let subRoutef (path : PrintfFormat<_,_,_,_, 'T>) (routeHandler : 'T -> HttpHandler) : HttpHandler =
+ validateFormat path
+ fun (next : HttpFunc) (ctx : HttpContext) ->
+ let paramCount = (path.Value.Split '/').Length
+ let subPathParts = (getPath ctx).Split '/'
+ if paramCount > subPathParts.Length then abort
+ else
+ let subPath =
+ subPathParts
+ |> Array.take paramCount
+ |> Array.fold (fun state elem ->
+ if String.IsNullOrEmpty elem
+ then state
+ else sprintf "%s/%s" state elem) ""
+ tryMatchInput path subPath false
+ |> function
+ | None -> abort
+ | Some args -> handlerWithRootedPath subPath (routeHandler args) next ctx
\ No newline at end of file
diff --git a/tests/Giraffe.Tests/Giraffe.Tests.fsproj b/tests/Giraffe.Tests/Giraffe.Tests.fsproj
index 5d4f0bb4..a5780dd9 100644
--- a/tests/Giraffe.Tests/Giraffe.Tests.fsproj
+++ b/tests/Giraffe.Tests/Giraffe.Tests.fsproj
@@ -34,6 +34,7 @@
+
diff --git a/tests/Giraffe.Tests/RoutingTests.fs b/tests/Giraffe.Tests/RoutingTests.fs
new file mode 100644
index 00000000..ecb9db32
--- /dev/null
+++ b/tests/Giraffe.Tests/RoutingTests.fs
@@ -0,0 +1,147 @@
+module Giraffe.Tests.RoutingTests
+
+open System.IO
+open System.Collections.Generic
+open Microsoft.AspNetCore.Http
+open Xunit
+open NSubstitute
+open Giraffe
+
+// ---------------------------------
+// subRoutef Tests
+// ---------------------------------
+
+[]
+let ``subRoutef: GET "/" returns "Not found"`` () =
+ let ctx = Substitute.For()
+ let app =
+ GET >=> choose [
+ subRoutef "/%s/%i" (fun (lang, version) ->
+ choose [
+ route "/foo" >=> text "bar"
+ routef "/%s" (fun name -> text (sprintf "Hello %s! Lang: %s, Version: %i" name lang version))
+ ])
+ route "/bar" >=> text "foo"
+ setStatusCode 404 >=> text "Not found" ]
+
+ ctx.Items.Returns (new Dictionary() :> IDictionary) |> ignore
+ ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
+ ctx.Request.Path.ReturnsForAnyArgs (PathString("/")) |> ignore
+ ctx.Response.Body <- new MemoryStream()
+ let expected = "Not found"
+
+ task {
+ let! result = app next ctx
+
+ match result with
+ | None -> assertFailf "Result was expected to be %s" expected
+ | Some ctx -> Assert.Equal(expected, getBody ctx)
+ }
+
+[]
+let ``subRoutef: GET "/bar" returns "foo"`` () =
+ let ctx = Substitute.For()
+ let app =
+ GET >=> choose [
+ subRoutef "/%s/%i" (fun (lang, version) ->
+ choose [
+ route "/foo" >=> text "bar"
+ routef "/%s" (fun name -> text (sprintf "Hello %s! Lang: %s, Version: %i" name lang version))
+ ])
+ route "/bar" >=> text "foo"
+ setStatusCode 404 >=> text "Not found" ]
+
+ ctx.Items.Returns (new Dictionary() :> IDictionary) |> ignore
+ ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
+ ctx.Request.Path.ReturnsForAnyArgs (PathString("/bar")) |> ignore
+ ctx.Response.Body <- new MemoryStream()
+ let expected = "foo"
+
+ task {
+ let! result = app next ctx
+
+ match result with
+ | None -> assertFailf "Result was expected to be %s" expected
+ | Some ctx -> Assert.Equal(expected, getBody ctx)
+ }
+
+[]
+let ``subRoutef: GET "/John/5/foo" returns "bar"`` () =
+ let ctx = Substitute.For()
+ let app =
+ GET >=> choose [
+ subRoutef "/%s/%i" (fun (lang, version) ->
+ choose [
+ route "/foo" >=> text "bar"
+ routef "/%s" (fun name -> text (sprintf "Hello %s! Lang: %s, Version: %i" name lang version))
+ ])
+ route "/bar" >=> text "foo"
+ setStatusCode 404 >=> text "Not found" ]
+
+ ctx.Items.Returns (new Dictionary() :> IDictionary) |> ignore
+ ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
+ ctx.Request.Path.ReturnsForAnyArgs (PathString("/John/5/foo")) |> ignore
+ ctx.Response.Body <- new MemoryStream()
+ let expected = "bar"
+
+ task {
+ let! result = app next ctx
+
+ match result with
+ | None -> assertFailf "Result was expected to be %s" expected
+ | Some ctx -> Assert.Equal(expected, getBody ctx)
+ }
+
+[]
+let ``subRoutef: GET "/en/10/Julia" returns "Hello Julia! Lang: en, Version: 10"`` () =
+ let ctx = Substitute.For()
+ let app =
+ GET >=> choose [
+ subRoutef "/%s/%i" (fun (lang, version) ->
+ choose [
+ route "/foo" >=> text "bar"
+ routef "/%s" (fun name -> text (sprintf "Hello %s! Lang: %s, Version: %i" name lang version))
+ ])
+ route "/bar" >=> text "foo"
+ setStatusCode 404 >=> text "Not found" ]
+
+ ctx.Items.Returns (new Dictionary() :> IDictionary) |> ignore
+ ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
+ ctx.Request.Path.ReturnsForAnyArgs (PathString("/en/10/Julia")) |> ignore
+ ctx.Response.Body <- new MemoryStream()
+ let expected = "Hello Julia! Lang: en, Version: 10"
+
+ task {
+ let! result = app next ctx
+
+ match result with
+ | None -> assertFailf "Result was expected to be %s" expected
+ | Some ctx -> Assert.Equal(expected, getBody ctx)
+ }
+
+[]
+let ``subRoutef: GET "/en/10/api/Julia" returns "Hello Julia! Lang: en, Version: 10"`` () =
+ let ctx = Substitute.For()
+ let app =
+ GET >=> choose [
+ subRoutef "/%s/%i/api" (fun (lang, version) ->
+ choose [
+ route "/foo" >=> text "bar"
+ routef "/%s" (fun name -> text (sprintf "Hello %s! Lang: %s, Version: %i" name lang version))
+ ])
+ route "/bar" >=> text "foo"
+ setStatusCode 404 >=> text "Not found" ]
+
+ ctx.Items.Returns (new Dictionary() :> IDictionary) |> ignore
+ ctx.Request.Method.ReturnsForAnyArgs "GET" |> ignore
+ ctx.Request.Path.ReturnsForAnyArgs (PathString("/en/10/api/Julia")) |> ignore
+ ctx.Response.Body <- new MemoryStream()
+ let expected = "Hello Julia! Lang: en, Version: 10"
+
+ task {
+ let! result = app next ctx
+
+ match result with
+ | None -> assertFailf "Result was expected to be %s" expected
+ | Some ctx -> Assert.Equal(expected, getBody ctx)
+ }
\ No newline at end of file