Install
| Product | Versions |
|---|---|
| .NET |
README
render_with_liquid: false
Oxpecker
Oxpecker is an F# framework based on ASP.NET Core Endpoint Routing (similar to Minimal APIs, so they are competitors) with easy to comprehend API, mostly inherited from Giraffe framework.
Nuget package dotnet add package Oxpecker
Examples can be found here
Performance tests reside here
Documentation:
An in depth functional reference to all of Oxpecker's features.
Table of contents
- Fundamentals
- Basics
- Web Request Processing
- Testing
Fundamentals
Core concepts
Oxpecker is built on top of the ASP.NET Core Endpoint Routing and provides some convenient DSL for F# users.
When using Oxpecker, make sure you are familiar with ASP.NET Core and its concepts, since Oxpecker reuses a lot of built-in functionality.
EndpointHandler
The main building block in Oxpecker is an EndpointHandler:
type EndpointHandler = HttpContext -> Task
an EndpointHandler is a function which takes HttpContext and returns a Task when finished.
EndpointHandler function has full control of the incoming HttpRequest and the resulting HttpResponse. It closely follows RequestDelegate signature, but in F# style.
EndpointHandler normally should be regarded as a terminal handler, meaning that it should write some result in response (but not necessary, as described in composition section).
EndpointMiddleware
type EndpointMiddleware = EndpointHandler -> HttpContext -> Task
EndpointMiddleware is similar to EndpointHandler, but it accepts the next EndpointHandler as first parameter.
Each EndpointMiddleware can process an incoming HttpRequest before passing it further down the Oxpecker pipeline by invoking the next EndpointMiddleware or short circuiting the execution by returning the Task itself.
EndpointHandler vs EndpointMiddleware
So, when should you define one or another? The answer lies in the responsibility of your handler:
- If you want to conditionally return response or proceed further in pipeline use
EndpointMiddleware. Good example is Preconditional endpoint middleware - If you want to execute some logic after the next handler completes - use
EndpointMiddleware - In other cases use
EndpointHandler
Oxpecker pipeline vs. ASP.NET Core pipeline
The Oxpecker pipeline is a (sort of) functional equivalent of the (object oriented) ASP.NET Core pipeline. The ASP.NET Core pipeline is defined by middlewares, and EndpointMiddleware is similar to regular middleware and EndpointHandler is similar to terminal middleware.
If the Oxpecker pipeline didn't process an incoming HttpRequest (because no route was matched) then other ASP.NET Core middleware can still process the request (e.g., static file middleware or another web framework plugged in after Oxpecker).
This architecture allows F# developers to build rich web applications through a functional composition of EndpointMiddleware and EndpointHandler functions while at the same time benefiting from the wider ASP.NET Core eco system by making use of already existing ASP.NET Core middleware.
The Oxpecker pipeline is plugged into the wider ASP.NET Core pipeline through the OxpeckerMiddleware itself and therefore an addition to it rather than a replacement.
Ways of creating a new EndpointHandler and EndpointMiddleware
There's multiple ways how one can create a new EndpointHandler in Oxpecker.
The easiest way is to re-use an existing EndpointHandler function:
let sayHelloWorld : EndpointHandler = text "Hello World, from Oxpecker"
You can also add additional parameters before returning an existing EndpointHandler function:
let sayHelloWorld (name: string) : EndpointHandler =
let greeting = sprintf "Hello World, from %s" name
text greeting
If you need to access the HttpContext object then you'll have to explicitly return an EndpointHandler function which accepts an HttpContext object and returns a Task:
let sayHelloWorld : EndpointHandler =
fun (ctx: HttpContext) ->
let name =
ctx.TryGetQueryValue "name"
|> Option.defaultValue "Oxpecker"
let greeting = sprintf "Hello World, from %s" name
text greeting ctx
The most verbose version of defining a new EndpointHandler function is by explicitly returning a Task. This is useful when an async operation needs to be called from within an EndpointHandler function:
type Person = { Name : string }
let sayHelloWorld : EndpointHandler =
fun (ctx: HttpContext) ->
task {
let! person = ctx.BindJson<Person>()
let greeting = sprintf "Hello World, from %s" person.Name
return! text greeting ctx
}
EndpointMiddleware is constructed very similarly to EndpointHandler, but it accepts an additional EndpointHandler as the first parameter:
let tryCatchMW : EndpointMiddleware =
fun (next: EndpointHandler) (ctx: HttpContext) ->
task {
try
return! next ctx
with
| ex ->
ctx.Response.StatusCode <- 500
return! text (sprintf "An error occurred: %s" ex.Message) ctx
}
Deferred execution of Tasks
Please be also aware that a Task<'T> in .NET is just a promise of 'T when a task eventually finishes asynchronously. Unless you define an EndpointHandler function in the most verbose way (with the task {} CE) and actively await a nested result with either let! or return! then the handler will not wait for the task to complete before returning to the OxpeckerMiddleware.
This has important implications if you want to execute code in an EndpointHandler after returned task completes, such as cleaning up resources with the use keyword. For example, in the code below, the IDisposable will get disposed before the actual response is returned. This is because a EndpointHandler is a HttpContext -> Task and therefore text "Hello" ctx only returns a Task which hasn't been completed yet:
let doSomething : EndpointHandler =
fun ctx ->
use __ = somethingToBeDisposedAtTheEndOfTheRequest
text "Hello" ctx
However, by explicitly invoking the text from within a task {} CE one can ensure that the text gets executed before the IDisposable gets disposed:
let doSomething : EndpointHandler =
fun (ctx: HttpContext) ->
task {
use __ = somethingToBeDisposedAtTheEndOfTheRequest
return! text "Hello" ctx
}
Composition
Handler composition
The fish operator (>=>) combines two functions into one.
It can compose
EndpointMiddlewareandEndpointMiddlewareEndpointMiddlewareandEndpointHandlerEndpointHandlerandEndpointHandler
It is an important combinator in Oxpecker which allows composing many smaller functions into a bigger web application:
There is no limit to how many functions can be chained with the fish operator:
let app =
route "/" (
setHttpHeader "X-Foo" "Bar"
>=> setStatusCode 200
>=> text "Hello World"
)
The idea is that every function can decide: short-circuit pipeline or proceed. For EndpointMiddleware it's choice whether to call next or not, and for EndpointHandler it's to start writing a response or not.
If you would like to learn more about the origins of the >=> (fish) operator then please check out Scott Wlaschin's blog post on Railway oriented programming.
routef function doesn't work with fish operator directly, so additional operators where added for route readability
routef "/{%s}" (setStatusCode 200 >>=> handler)
routef "/{%s}/{%s}" (setStatusCode 200 >>=>+ handler)
routef "/{%s}/{%s}/{%s}" (setStatusCode 200 >>=>++ handler)
Bind composition
bindQuery, bindForm, bindJson helpers can be composed with handlers
route "/test" (bindQuery handler)
routef requires additional operators for composition with bind* functions as well
routef "/{%s}" (bindQuery << handler)
routef "/{%s}/{%s}" (bindForm <<+ handler)
routef "/{%s}/{%s}/{%s}" (bindJson <<++ handler)
Multi-route filter
Sometimes you want to use some generic handler or middleware not only with one route, but with the whole collection of routes. It is possible using addFilter (similar to MinimalApis), for example:
let webApp = [
subRoute "/auth" [
route "/open" handler1
route "/closed" handler2
] |> addFilter (setHttpHeader "myHeader" "myValue")
]
Continue vs. Return
In Oxpecker there are two scenarios which a given EndpointMiddleware or EndpointHandler can use:
- Continue with next handler
- Return early
Continue
An example is a hypothetical middleware, which sets a given HTTP header and afterwards always calls into the next http handler:
let setHttpHeader key value : EndpointMiddleware =
fun (next: HttpFunc) (ctx: HttpContext) ->
ctx.SetHttpHeader key value
next ctx
A middleware performs some actions on the HttpRequest and/or HttpResponse object and then invokes the next handler to continue with the pipeline.
It can also be implemented as an EndpointHandler:
let setHttpHeader key value : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.SetHttpHeader key value
Task.CompletedTask
If such a handler is used in the middle of the pipeline, the next handler will be invoked, because the ctx.Response.HasStarted will return false.
If it will reside in the end of the pipeline, then the response will start anyway, since there's no next handler to be invoked.
Return early
Sometimes an EndpointHandler or EndpointMiddleware wants to return early and not continue with the remaining pipeline.
A typical example would be an authentication or authorization handler, which would not continue with the remaining pipeline if a user wasn't authenticated. Instead it might want to return a 401 Unauthorized response:
let checkUserIsLoggedIn : EndpointMiddleware =
fun (next: EndpointHandler) (ctx: HttpContext) ->
if isNotNull ctx.User && ctx.User.Identity.IsAuthenticated then
next ctx
else
setStatusCode 401 ctx
Task.CompletedTask
In the else clause the checkUserIsLoggedIn handler returns a 401 Unauthorized HTTP response and skips the remaining EndpointHandler pipeline by not invoking next but an already completed task.
If you were to have an EndpointMiddleware defined with the task {} CE then you could rewrite it in the following way:
let checkUserIsLoggedIn : EndpointMiddleware =
fun (next: EndpointHandler) (ctx: HttpContext) ->
task {
if isNotNull ctx.User && ctx.User.Identity.IsAuthenticated then
return! next ctx
else
return ctx.SetStatusCode 401
}
It is also possible to implement this using EndpointHandler, however the response has to be explicitly started:
let checkUserIsLoggedIn : EndpointHandler =
fun (ctx: HttpContext) ->
if isNotNull ctx.User && ctx.User.Identity.IsAuthenticated then
Task.CompletedTask
else
ctx.SetStatusCode 401
text "Unauthorized" ctx // start response
Basics
Plugging Oxpecker into ASP.NET Core
Install the Oxpecker NuGet package:
PM> Install-Package Oxpecker
Create a web application and plug it into the ASP.NET Core middleware:
open Oxpecker
// usually your application consists of several routes
let webApp = [
route "/" <| text "Hello world"
route "/ping" <| text "pong"
]
// sometimes it can only be a single route
let webApp1 = route "/" <| "Hello Oxpecker"
// or it can only be a single "MultiEndpoint" route
let webApp2 = GET [
route "/" <| "Hello Oxpecker"
]
let configureApp (appBuilder: IApplicationBuilder) =
appBuilder
.UseRouting()
.UseOxpecker(webApp) // Add Oxpecker to the ASP.NET Core pipeline, should go after UseRouting
//.UseOxpecker(webApp1) will work
//.UseOxpecker(webApp2) will also work
|> ignore
let configureServices (services: IServiceCollection) =
services
.AddRouting()
.AddOxpecker() // Register default Oxpecker dependencies
|> ignore
[<EntryPoint>]
let main _ =
let builder = WebApplication.CreateBuilder(args)
configureServices builder.Services
let app = builder.Build()
configureApp app
app.Run()
0
Dependency Management
ASP.NET Core has built-in dependency management which works out of the box with Oxpecker.
Registering Services
Registering services is done the same way as it is done for any other ASP.NET Core web application:
let configureServices (services : IServiceCollection) =
// Add default Oxpecker dependencies
services.AddOxpecker() |> ignore
// Add other dependencies
// ...
Retrieving Services
Retrieving registered services from within a Oxpecker EndpointHandler function can be done through the built in service locator (RequestServices) which comes with an HttpContext object:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let fooBar =
ctx.RequestServices.GetService(typeof<IFooBar>)
:?> IFooBar
// Do something with `fooBar`...
// Return a Task
Oxpecker has an additional HttpContext extension method called GetService<'T> to make the code less cumbersome:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let fooBar = ctx.GetService<IFooBar>()
// Do something with `fooBar`...
// Return a Task
There's a handful more extension methods available to retrieve a few default dependencies like an IWebHostEnvironment or ILogger object which are covered in the respective sections of this document.
Functional DI
However, if you prefer to use a more functional approach to dependency injection, you shouldn't use container based approach, but rather follow the Env strategy.
The approach is described in the article https://medium.com/@lanayx/dependency-injection-in-f-the-missing-manual-d376e9cafd0f , and to see how it looks in practice, you can refer to the CRUD example in the repository.
Multiple Environments and Configuration
ASP.NET Core has built in support for working with multiple environments and configuration management, which both work out of the box with Oxpecker.
Additionally Oxpecker exposes a GetHostingEnvironment() extension method which can be used to easier retrieve an IWebHostEnvironment object from within an EndpointHandler function:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let env = ctx.GetHostingEnvironment()
// Do something with `env`...
// Return a Task
Configuration options can be retrieved via the GetService<'T> extension method:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let settings = ctx.GetService<IOptions<MySettings>>()
// Do something with `settings`...
// Return a Task
If you need to access the configuration when configuring services, you can access it like this:
let configureServices (services: IServiceCollection) =
let serviceProvider = services.BuildServiceProvider()
let settings = serviceProvider.GetService<IConfiguration>()
// Configure services using the `settings`...
services.AddOxpecker() |> ignore
Logging
ASP.NET Core has a built in Logging API which works out of the box with Oxpecker.
Logging from within an EndpointHandler function
You can retrieve an ILogger object (which can be used for logging) through the GetLogger<'T>() or GetLogger (categoryName : string) extension methods:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
// Retrieve an ILogger through one of the extension methods
let loggerA = ctx.GetLogger<ModuleName>()
let loggerB = ctx.GetLogger("someHandler")
// Log some data
loggerA.LogCritical("Something critical")
loggerB.LogInformation("Logging some random info")
// etc.
// Return a Task
Error Handling
Oxpecker provides the default middleware for exceptions handling and the defaut handler for unmatched routes. You can use them as follows:
let configureApp (appBuilder: IApplicationBuilder) =
appBuilder
.UseRouting()
.Use(Default.exceptionMiddleware) // Add exception handling middleware BEFORE Oxpecker
.UseOxpecker(endpoints)
.Run(Default.notFoundHandler) // Add not found middleware AFTER Oxpecker
You can use those default implementations as is or as examples for your own custom error handling logic.
Default.exceptionMiddleware answers ModelBindException and RouteParseException with 400, AntiforgeryValidationException with 403 and any other exception with 500, logging them as warnings or errors. An OperationCanceledException raised while ctx.RequestAborted is cancelled means that the client disconnected: it is only logged at Debug level and, if the response has not started and its BodyWriter reports no unflushed bytes, answered with 499 Client Closed Request (see Cancellation). If the cancellation was triggered by ASP.NET Core's request timeouts middleware instead, it is logged as a warning and answered with 504 Gateway Timeout under the same conditions. No body is written in either case, since the request token is already cancelled. If the response has already started, has queued output, or its writer cannot report unflushed bytes, the middleware aborts the connection instead (ctx.Abort()): a client that is still connected, as after a request timeout, then sees a failed transfer rather than cancelled output or a well-formed but truncated body. Response.Clear() does not discard bytes queued in BodyWriter, even when HasStarted is false. To let a custom timeout status code or response configured on the timeout policy take effect, register Default.exceptionMiddleware before UseRequestTimeouts, so that the timeouts middleware handles the timeout itself.
Web Request Processing
Oxpecker comes with a large set of default HttpContext extension methods as well as default EndpointHandler functions which can be used to build rich web applications.
HTTP Headers
Working with HTTP headers in Oxpecker is plain simple. The TryGetHeaderValue (key: string) extension method tries to retrieve the value of a given HTTP header and then returns either Some string or None:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let someValue =
match ctx.TryGetHeaderValue "X-MyOwnHeader" with
| None -> "default value"
| Some headerValue -> headerValue
// Do something with `someValue`...
// Return a Task
Setting an HTTP header in the response can be done via the SetHttpHeader (key: string) (value: obj) extension method:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.SetHttpHeader "X-CustomHeader" "some-value"
// Do other stuff...
// Return a Task
You can also set an HTTP header via the setHttpHeader http handler:
let customHeader : EndpointHandler =
setHttpHeader "X-CustomHeader" "Some value"
let webApp = [
route "/foo" (customHeader >=> text "Foo")
]
Please note that these are additional Oxpecker functions which complement already existing HTTP header functionality in the ASP.NET Core framework. ASP.NET Core offers higher level HTTP header functionality through the ctx.Request.GetTypedHeaders() method.
HTTP Verbs
Oxpecker exposes a set of functions which can filter a request based on the request's HTTP verb:
GETPOSTPUTPATCHQUERYDELETEHEADOPTIONSTRACECONNECT
There is an additional GET_HEAD handler which can filter an HTTP GET and HEAD request at the same time.
Filtering requests based on their HTTP verb can be useful when implementing a route which should behave differently based on the verb (e.g., GET vs. POST):
let submitFooHandler : EndpointHandler =
// Do something
let submitBarHandler : EndpointHandler =
// Do something
let webApp = [
// Filters for GET requests
GET [
route "/foo" <| text "Foo"
route "/bar" <| text "Bar"
]
// Filters for POST requests
POST [
route "/foo" <| submitFooHandler
route "/bar" <| submitBarHandler
]
]
If you need to check the request's HTTP verb from within an EndpointHandler function then you can use the default ASP.NET Core HttpMethods class:
open Microsoft.AspNetCore.Http
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
if HttpMethods.IsPut ctx.Request.Method then
// Do something
else
// Do something else
// Return a Task
The GET_HEAD is a special function which can be used to enable GET and HEAD requests on a resource at the same time. This can be very useful when caching is enabled and clients might want to send HEAD requests to check the ETag or Last-Modified HTTP headers before issuing a GET.
You can also create custom combinations of HTTP verbs by using the applyHttpVerbsToEndpoints function:
let GET_HEAD_OPTIONS: Endpoint seq -> Endpoint =
applyHttpVerbsToEndpoints(Verbs [ HttpVerb.GET; HttpVerb.HEAD; HttpVerb.OPTIONS ])
let webApp = [
GET_HEAD_OPTIONS [
route "/foo" <| text "Foo"
route "/bar" <| text "Bar"
]
]
HTTP Status Codes
Setting the HTTP status code of a response can be done either via the SetStatusCode (httpStatusCode: int) extension method or with the setStatusCode (statusCode: int) function:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.SetStatusCode 200
// Return a Task
// or...
let someHandler : EndpointHandler =
setStatusCode 200
>=> text "Hello World"
Routing
Oxpecker offers several routing functions to accommodate the majority of use cases. Note, that Oxpecker routing is sitting on the top of ASP.NET Core endpoint routing, so all routes are case insensitive.
route
The simplest form of routing can be done with the route http handler:
let webApp = [
route "/foo" <| text "Foo"
route "/bar" <| text "Bar"
]
routef
If a route contains user defined parameters then the routef http handler can be handy:
let fooHandler first last age : EndpointHandler =
fun (ctx: HttpContext) ->
(sprintf "First: %s, Last: %s, Age: %i" first last age
|> text) ctx
let webApp = [
routef "/foo/{%s:maxlength(8)}/{%s}/{%i}" fooHandler
routef "/bar/{%O:guid}" (fun (guid: Guid) -> text (string guid))
]
The routef http handler takes two parameters - a format string and an EndpointHandler function. The built-in ASP.NET route constraints are fully supported as well.
The format string supports the following format chars:
| Format Char | Type |
|---|---|
%b |
bool |
%c |
char |
%s |
string |
%i |
int |
%d |
int64 |
%f |
float/double |
%u |
uint64 |
%O:guid |
Guid |
Note: routef handler can only handle up to 5 route parameters. It's not recommended to use more than 3 parameters in a route, but if you really need a lot, you can use route with EndpointHandler function that utilizes .TryGetRouteValue extension.
route "/{a}/{b}/{c}/{d}/{e}/{f}" (fun ctx ->
let a = ctx.TryGetRouteValue("a") |> Option.defaultValue ""
let b = ctx.TryGetRouteValue("b") |> Option.defaultValue ""
let c = ctx.TryGetRouteValue("c") |> Option.defaultValue ""
let d = ctx.TryGetRouteValue("d") |> Option.defaultValue ""
let e = ctx.TryGetRouteValue("e") |> Option.defaultValue ""
let f = ctx.TryGetRouteValue("f") |> Option.defaultValue ""
text (sprintf "%s %s %s %s %s %s", a b c d e f) ctx
)
subRoute
It lets you categorise routes without having to repeat already pre-filtered parts of the route:
let webApp =
subRoute "/api" [
subRoute "/v1" [
route "/foo" <| text "Foo 1"
route "/bar" <| text "Bar 1"
]
subRoute "/v2" [
route "/foo" <| text "Foo 2"
route "/bar" <| text "Bar 2"
]
]
In this example the final URL to retrieve "Bar 2" would be http[s]://your-domain.com/api/v2/bar.
subRoutef
It's a combination of subRoute and routef: route parameters of the parent route are parsed and passed to a function that returns child endpoints (any Endpoint seq: list, array or seq { }), so they are available to every child handler:
let webApp = [
subRoutef "/users/{%i}" (fun userId -> [
route "/profile" (profileHandler userId)
routef "/posts/{%O:guid}" (fun postId -> postHandler userId postId)
GET [ route "/settings" (settingsHandler userId) ]
])
]
In this example profileHandler gets 42 as userId for the URL http[s]://your-domain.com/users/42/profile. The format string supports the same format chars and route constraints as routef, except for catch-all parameters ({*%s} and {**%s}), since child routes have to follow the parameters.
Since endpoints have to be registered at startup, while parameter values are known only per request, the function is called at startup with placeholder values (to get the endpoints structure and configuration) and then on every request with real values (to get the handler). The function of a subRoutef nested in another subRoutef is called several times per request, since rebuilding the outer endpoints calls it with placeholder values again. Hence:
- Number, order, nesting, templates and HTTP verbs of the returned endpoints must not depend on the parameter values; this is checked on every request and an exception is thrown otherwise. Endpoint configuration (
configureEndpoint,addMetadata, etc.) is taken from the startup call, so it must not depend on them either. - The function must only construct endpoints: keep it cheap and free of side effects, since it can run several times per request.
- Route parameter names are taken from the function parameter names, so they must be unique across the whole route (including nested
routefandsubRoutefparameters).
addMetadata
It lets you add metadata to a route which can be used later on in the pipeline:
let webApp =
GET [
route "/foo" (text "Foo") |> addMetadata "foo"
]
configureEndpoint
This function allows you to configure an endpoint using ASP.NET .With* extension methods:
let webApp =
GET [
route "/foo" (text "Foo")
|> configureEndpoint
_.WithMetadata("foo")
.WithDisplayName("Foo")
]
Catch-all route
You can leverage the templating system of ASP.NET core routing with Oxpecker to define catch-all routes with both route and routef functions by using asterisk * or double asterisk **:
let webApp =
GET [
route "/foo/{*bar}" (fun ctx ->
text (ctx.TryGetRouteValue("bar") |> Option.defaultValue "") ctx)
routef "/moo/{**%s}" (fun bar -> text bar)
]
Query Strings
Working with query strings is very similar to working with HTTP headers in Oxpecker. The TryGetQueryValue (key : string) extension method tries to retrieve the value of a given query string parameter and then returns either Some string or None:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
let someValue =
match ctx.TryGetQueryValue "q" with
| None -> "default value"
| Some q -> q
// Do something with `someValue`...
// Return a Task
You can also access the query string through the ctx.Request.Query object which returns an IQueryCollection object which allows you to perform more actions on it.
Last but not least there is also an HttpContext extension method called BindQuery<'T> which lets you bind an entire query string to an object of type 'T (see Binding Query Strings).
Model Binding
Oxpecker offers out of the box a few default HttpContext extension methods and equivalent EndpointHandler functions which make it possible to bind the payload or query string of an HTTP request to a custom object. BindJson and BindForm read the request body with ctx.RequestAborted, so when the client disconnects they throw an OperationCanceledException rather than a ModelBindException (see Cancellation).
Binding JSON
The BindJson<'T>() extension method can be used to bind a JSON payload to an object of type 'T:
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let submitCar : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Binds a JSON payload to a Car object
let! car = ctx.BindJson<Car>()
// Sends the object back to the client
return! ctx.Write <| TypedResults.Ok car
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" submitCar
]
]
Alternatively you can also use the bindJson<'T> http handler:
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" (bindJson<Car> (fun car -> %TypedResults.Ok car))
]
]
Both, the HttpContext extension method as well as the EndpointHandler function will try to create an instance of type 'T regardless if the submitted payload contained a complete representation of 'T or not. The parsed object might only contain partial data (where some properties might be null) and additional null checks might be required before further processing.
Please note that in order for the model binding to work the record type must be decorated with the [<CLIMutable>] attribute, which will make sure that the type will have a parameterless constructor.
The underlying JSON serializer can be configured as a dependency during application startup (see JSON).
Binding Forms
The BindForm<'T> extension method binds form data to an object of type 'T:
With the default model binder, an empty form body creates a model with default field values (such as 0, false, None, or null). A supported form Content-Type is still required; missing or unsupported content types throw ModelBindException.
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let submitCar : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Binds a form payload to a Car object
let! car = ctx.BindForm<Car>()
// or with a CultureInfo:
let british = CultureInfo.CreateSpecificCulture("en-GB")
let! car2 = ctx.BindForm<Car>(british)
// Sends the object back to the client
return! ctx.Write <| Ok car
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [ route "/car" submitCar ]
]
Alternatively you can use the bindForm<'T> http handlers:
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" (bindForm<Car> (fun model -> %Ok model))
]
]
Just like in the previous examples the record type must be decorated with the [<CLIMutable>] attribute in order for the model binding to work.
Collections of supported values, including strings, numbers, booleans, enums, nullable values, and options, accept repeated keys (tags=dotnet&tags=mvc&tags=api) and indexed keys (tags[0]=dotnet&tags[1]=mvc&tags[2]=api). Indexed keys are bound sequentially from index 0 and, as in ASP.NET Core, binding stops at the first missing index (values after a gap are ignored). When the same index is sent multiple times (e.g. the checkbox + hidden-input fallback idiom) the first value wins. Indexed collection binding is capped by ModelBinderOptions.MaxCollectionSize (default 1024); indices that reach or exceed the limit fail binding.
The underlying model binder is configured as a dependency during application startup:
let configureServices (services : IServiceCollection) =
// First register all default Oxpecker dependencies
services.AddOxpecker() |> ignore
// Now register custom model binder
services.AddSingleton<IModelBinder>(CustomModelBinder()) |> ignore
// or use default model binder, but with different options
services.AddSingleton<IModelBinder>(ModelBinder(specificOptions)) |> ignore
Binding Query Strings
The BindQuery<'T> extension method binds query string parameters to an object of type 'T:
With the default model binder, an absent or empty query string creates a model with default field values (such as 0, false, None, or null). No query-count check is needed before calling BindQuery.
Collections accept the same repeated and indexed key formats as form binding.
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let submitCar : EndpointHandler =
fun (ctx: HttpContext) ->
// Binds the query string to a Car object
let car = ctx.BindQuery<Car>()
// or with a CultureInfo:
let british = CultureInfo.CreateSpecificCulture("en-GB")
let car2 = ctx.BindQuery<Car>(british)
// Sends the object back to the client
ctx.Write <| Ok car
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
route "/car" <| submitCar
]
]
Alternatively you can use the bindQuery<'T> http handler:
[<CLIMutable>]
type Car = {
Name : string
Make : string
Wheels : int
Built : DateTime
}
let webApp = [
GET [
route "/" <| text "index"
route "ping" <| text "pong"
]
POST [
route "/car" (bindQuery<Car> (fun model -> %Ok model))
]
]
Just like in the previous examples the record type must be decorated with the [<CLIMutable>] attribute in order for the model binding to work.
The underlying model binder is configured as a dependency during application startup (see Binding Forms)
Model validation
Oxpecker diverges from the Giraffe's approach to model validation and embraces the traditional ASP.NET Core model validation based on System.ComponentModel.DataAnnotations.Validator (link).
While you might still need to do complex validation inside your domain, the built-in DTO model validation is still useful for the API boundary.
You have 3 ways to validate your model:
- Directly using
validateModel*functions - Using
ctx.BindAndValidate*extension methods (similar toctx.Bind*) - Using
bindAndValidate*handlers (similar tobind*)
Inside handler you'll need to match ModelValidationResult to handle both valid and invalid cases:
open System.ComponentModel.DataAnnotations
[<CLIMutable>]
type Car = {
[<Required>]
Name: string
[<Required>]
Make: string
[<Range(1, 10)>]
Wheels: int
Built: DateTime
}
let addCar : EndpointHandler =
fun (ctx: HttpContext) ->
task {
match! ctx.BindAndValidateJson<Car>() with
| ModelValidationResult.Valid car ->
return! ctx.Write <| Ok car
| ModelValidationResult.Invalid (invalidCar, errors) ->
return! ctx.Write <| BadRequest errors.All
}
If your DTO validation logic requires accessing dynamic data from your services, it is possible to implement the IValidatableObject
(link) interface and access the service
provider from the ValidationContext object:
open System.ComponentModel.DataAnnotations
[<CLIMutable>]
type Car = {
[<Required>]
Name: string
[<Required>]
Make: string
[<Range(1, 10)>]
Wheels: int
Built: DateTime
} with
interface IValidatableObject with
member this.Validate(ctx: ValidationContext) =
let utcNow = ctx.GetService<TimeProvider>().GetUtcNow()
seq {
if this.Built > utcNow then
yield ValidationResult("Invalid date", [ nameof this.Built ])
}
let addCar : EndpointHandler =
fun (ctx: HttpContext) ->
task {
match! ctx.BindAndValidateJson<Car>() with
| ModelValidationResult.Valid car ->
return! ctx.Write <| Ok car
| ModelValidationResult.Invalid (invalidCar, errors) ->
return! ctx.Write <| BadRequest errors.All
}
If you are using server-side rendering using Oxpecker.ViewEngine, you can leverage special ModelState
[<RequireQualifiedAccess>]
type ModelState<'T> =
| Empty
| Valid of 'T
| Invalid of InvalidModel<'T>
This type is intended to be used for create/edit pages to simplify passing validation data to the view. An example of usage can be found in the ContactApp example.
File Upload
ASP.NET Core makes it really easy to process uploaded files.
The HttpContext.Request.Form.Files collection can be used to process one or many small files which have been sent by a client:
let fileUploadHandler : EndpointHandler =
fun (ctx: HttpContext) ->
match ctx.Request.HasFormContentType with
| false ->
ctx.Write <| BadRequest()
| true ->
ctx.Request.Form.Files
|> Seq.fold (fun acc file -> $"{acc}\n{file.FileName}") ""
|> ctx.WriteText
let webApp = [ route "/upload" fileUploadHandler ]
You can also read uploaded files by utilizing the IFormFeature and the ReadFormAsync method:
let fileUploadHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
let formFeature = ctx.Features.Get<IFormFeature>()
let! form = formFeature.ReadFormAsync ctx.RequestAborted
return!
form.Files
|> Seq.fold (fun acc file -> $"{acc}\n{file.FileName}") ""
|> ctx.WriteText
}
let webApp = [ route "/upload" fileUploadHandler ]
For large file uploads it is recommended to stream the file in order to prevent resource exhaustion.
See also large file uploads in ASP.NET Core on StackOverflow.
WebSockets
Oxpecker's doesn't provide any additional wrappers and fully relies on ASP.NET Core WebSocket support:
let configureApp (appBuilder: IApplicationBuilder) =
appBuilder
.UseRouting()
.UseOxpecker(webApp) // Add Oxpecker
.UseWebSockets() // Add WebSockets
|> ignore
Grpc
Oxpecker's doesn't provide any additional wrappers and fully relies on ASP.NET Core Grpc support. However, to generate classes from .proto files you need to create a C# project. So, here is a list of steps:
- Create a new C# project, add your
.protofile(s) there (also reference required packages likeGrpc.Tools,Grpc.CoreandGoogle.Protobuf) - Build the C# project, make sure build succeeds
- Reference the C# project from your F# project containing Oxpecker's API
- Implement the Grpc service in F# using generated classes. Example:
type GreeterService() =
inherit Greeter.GreeterBase()
override _.SayHello (request, _) =
HelloReply(Message = $"Hello {request.Name}") |> Task.FromResult
- Add
Grpc.AspNetCore.ServerNuGet package to the F# project - Configure the Grpc service in the F# project:
let configureServices (services: IServiceCollection) =
services
// other dependencies
.AddGrpc() // Register GRPC dependencies
|> ignore
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
configureServices builder.Services
let app = builder.Build()
app.MapGrpcService<GreeterService>() |> ignore // expose Grpc endpoint
app.Run()
0
Security
Oxpecker's security model is built on top of the ASP.NET Core security model and is very similar to the Minimal API's one, so make sure you are familiar with it.
Authentication and Authorization
Oxpecker's auth works just like in Minimal APIs.
To protect endpoints, call configureEndpoint _.RequireAuthorization on a single endpoint or a group of endpoints.
let webApp = [
// single endpoint
route "/" (text "Hello World")
|> configureEndpoint _.RequireAuthorization()
// endpoint group
GET [
route "/index" <| text "index"
route "/ping" <| text "pong"
] |> configureEndpoint _.RequireAuthorization(
AuthorizeAttribute(AuthenticationSchemes = "MyScheme")
)
]
CSRF protection
Default Oxpecker implementation is similar to the Minimal API's one.
First, you need to add AntiForgery middleware to your pipeline and register dependencies:
let configureApp (appBuilder: WebApplication) =
appBuilder
.UseRouting()
.UseAntiforgery() // should be added between UseRouting and UseOxpecker
.UseOxpecker(endpoints) |> ignore
let configureServices (services: IServiceCollection) =
services
.AddRouting()
.AddAntiforgery()
.AddOxpecker()
|> ignore
Once it's done, all your POST, PUT and PATCH endpoints will be validated against CSRF token, but an actual antiforgery exception (in case of failed validation) will only happen when doing form binding. Note that QUERY endpoints are not validated even though QUERY requests can carry a body. This is by design of the QUERY method itself: it is defined as safe and idempotent, just like GET, so CSRF protection doesn't apply to it.
If you don't like the default behavior, instead of adding built-in ASP.NET Core AntiForgery middleware, you can write custom EndpointMiddleware, that will call antiforgery.ValidateRequestAsync, and place it in the desired stage of the pipeline.
To place Antiforgery token in the form, you should use GetAntiforgeryInput extension method:
let myForm (ctx: HttpContext) =
form() {
ctx.GetAntiforgeryInput()
input(type'="text", name="Message", value="Hello")
button(type'="submit") { "Submit" }
}
If you don't use Oxpecker.ViewEngine or prefer header to the form field, you can leverage GetAntiforgeryTokens extension method instead.
Conditional Requests
Conditional HTTP headers (e.g., If-Match, If-Modified-Since, etc.) are a common pattern to improve performance (web caching), to combat the lost update problem or to perform optimistic concurrency control when a client requests a resource from a web server.
Oxpecker offers the validatePreconditions endpoint handler which can be used to run HTTP pre-validation checks against a given ETag and/or Last-Modified value of an incoming HTTP request:
let someHandler (eTag : string)
(lastModified : DateTimeOffset)
(content : string) =
let eTagHeader = Some (EntityTagHelper.createETag eTag)
validatePreconditions eTagHeader (Some lastModified) >=> text content
The validatePreconditions middleware takes in two optional parameters - an eTag and a lastMofified date time value - which will be used to validate a conditional HTTP request. If all conditions can be met, or if no conditions have been submitted, then the next http handler (of the Oxpecker pipeline) will get invoked. Otherwise, if one of the pre-conditions fails or if the resource hasn't changed since the last check, then a 412 Precondition Failed or a 304 Not Modified response will get returned.
The ETag (Entity Tag) value is an opaque identifier assigned by a web server to a specific version of a resource found at a URL. The Last-Modified value provides a timestamp indicating the date and time at which the origin server believes the selected representation was last modified.
Oxpecker's validatePreconditions endpoint middleware validates the following conditional HTTP headers:
If-MatchIf-None-MatchIf-Modified-SinceIf-Unmodified-Since
The If-Range HTTP header will not get validated as part the validatePreconditions http handler, because it is a streaming specific check which gets handled by Oxpecker's Streaming functionality.
Alternatively Oxpecker exposes the HttpContext extension method ValidatePreconditions(eTag, lastModified) which can be used to create a custom conditional endpoint middleware. The ValidatePreconditions method takes the same two optional parameters and returns a result of type Precondition.
The Precondition union type contains the following cases:
| Case | Description and Recommended Action |
|---|---|
NoConditionsSpecified |
No validation has taken place, because the client didn't send any conditional HTTP headers. Proceed with web request as normal. |
ConditionFailed |
At least one condition couldn't be satisfied. It is advised to return a 412 status code back to the client (you can use the HttpContext.PreconditionFailedResponse() method for that purpose). |
ResourceNotModified |
The resource hasn't changed since the last visit. The server can skip processing this request and return a 304 status code back to the client (you can use the HttpContext.NotModifiedResponse() method for that purpose). |
AllConditionsMet |
All pre-conditions were satisfied. The server should continue processing the request as normal. |
The validatePreconditions http handler as well as the ValidatePreconditions extension method will not only validate all conditional HTTP headers, but also set the required ETag and/or Last-Modified HTTP response headers according to the HTTP spec.
Both functions follow latest HTTP guidelines and validate all conditional headers in the correct precedence as defined in RFC 2616.
Example of HttpContext.ValidatePreconditions:
// Pass an optional eTag and lastModified timestamp into the handler, because generating an eTag might require to load the entire resource into memory and therefore this is not something which should be done on every request.
let someHttpHandler eTag lastModified : EndpointHandler =
fun (ctx: HttpContext) ->
task {
match ctx.ValidatePreconditions(eTag, lastModified) with
| ConditionFailed -> return ctx.PreconditionFailedResponse()
| ResourceNotModified -> return ctx.NotModifiedResponse()
| AllConditionsMet | NoConditionsSpecified ->
// Continue as normal
// Do stuff
}
let webApp = [
route "/" <| text "Hello World"
route "/foo" <| someHttpHandler None None
]
Response Writing
Sending a response back to a client in Oxpecker can be done through a small range of HttpContext extension methods and their equivalent EndpointHandler functions.
Writing Bytes
The WriteBytes (data: byte array) extension method and the bytes (data: byte array) endpoint handler both write a byte array to the response stream of the HTTP request:
let someHandler (data: byte array) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteBytes data
}
// or...
let someHandler (data: byte array) : EndpointHandler =
// Do stuff
bytes data
Both functions will also set the Content-Length HTTP header to the length of the byte array.
The bytes http handler (and its HttpContext extension method equivalent) is useful when you want to create your own response writing function for a specific media type which is not provided by Oxpecker yet.
For example Oxpecker doesn't have any functionality for serializing and writing a YAML response back to a client. However, you can reference another third party library which can serialize an object into a YAML string and then create your own yaml http handler like this:
let yaml (x: obj) : EndpointHandler =
setHttpHeader "Content-Type" "text/yaml"
>=> bytes (x |> YamlSerializer.toYaml |> Encoding.UTF8.GetBytes)
Writing Text
The WriteText (str : string) extension method and the text (str: string) endpoint handler will write string to response in UTF8 format and also set the Content-Type HTTP header to text/plain in the response:
let someHandler (str: string) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteText str
}
// or...
let someHandler (str: string) : EndpointHandler =
// Do stuff
text str
Writing JSON
The WriteJson<'T> (dataObj : 'T) extension method and the json<'T> (dataObj: 'T) endpoint handler will both serialize an object to a JSON string and write the output to the response stream of the HTTP request. They will also set the Content-Length HTTP header and the Content-Type header to application/json in the response:
let someHandler (animal: Animal) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteJson animal
}
// or...
let someHandler (animal: Animal) : EndpointHandler =
// Do stuff
json animal
The WriteJsonChunked<'T> (dataObj: 'T) extension method and the jsonChunked (dataObj: 'T) endpoint handler write directly to the response stream of the HTTP request without extra buffering into a byte array. They will not set a Content-Length header and instead set the Transfer-Encoding: chunked header and Content-Type: application/json:
let someHandler (person: Person) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteJsonChunked person
}
// or...
let someHandler (person: Person) : EndpointHandler =
// Do stuff
jsonChunked person
The underlying JSON serializer is configured as a dependency during application startup and defaults to System.Text.Json (when you write services.AddOxpecker()). You can implement IJsonSerializer interface to plug in custom JSON serializer or to pass different options to the default one.
let configureServices (services : IServiceCollection) =
// First register all default Oxpecker dependencies
services.AddOxpecker() |> ignore
// Now register custom serializer
services.AddSingleton<IJsonSerializer>(CustomSerializer()) |> ignore
// or use default STJ serializer, but with different options
services.AddSingleton<IJsonSerializer>(
SystemTextJsonSerializer(specificOptions)) |> ignore
IJsonSerializer has three members: Serialize(value, ctx, chunked) writes a value as the response body and sets the Content-Type header (and Content-Length, unless chunked), Deserialize(ctx) reads a value from the request body, and SerializePart(value, writer, cancellationToken) writes a value as UTF-8 JSON to a PipeWriter without touching the response headers. The latter is used by MultipartPart.Json, so the JSON parts of a multipart response follow the registered serializer as well. An implementation of SerializePart must not complete the writer (a serializer that needs a Stream can use writer.AsStream(leaveOpen = true)) and does not have to flush it, but it has to pass the token to every asynchronous write, just like ctx.RequestAborted in the other two members.
Writing IResult
If you like what ASP.NET Core IResult offers, you might be pleased to know that Oxpecker supports it as well. You can simplify returning responses together with status codes using Microsoft.AspNetCore.Http.TypedResults:
open Oxpecker
open type Microsoft.AspNetCore.Http.TypedResults
let johnDoe = {|
FirstName = "John"
LastName = "Doe"
|}
let app = [
route "/" <| text "Hello World"
route "/john" <| %Ok johnDoe // returns 200 OK with JSON body
route "/bad" <| %BadRequest() // returns 400 BadRequest with empty body
]
The % operator is used to convert IResult to EndpointHandler. You can also do the conversion inside EndpointHandler using .Write extension method:
let myHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.Write <| TypedResults.Ok johnDoe
Writing HTML Views
Oxpecker comes with its own extremely powerful view engine for functional developers (see Oxpecker View Engine). The WriteHtmlView (htmlView : HtmlElement) extension method and the htmlView (htmlView : HtmlElement) HTTP handler will both compile a given html view into valid HTML code and write the output to the response stream of the HTTP request. Additionally they will both set the Content-Length HTTP header to the correct value and set the Content-Type header to text/html:
let indexView =
html() {
head() {
title() { "Oxpecker" }
}
body() {
h1(id="Header") { "Oxpecker" }
p() { "Hello World." }
}
}
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteHtmlView indexView
}
// or...
let someHandler : EndpointHandler =
// Do stuff
htmlView indexView
You can also use HTML streaming using htmlChunked and htmlViewChunked HTTP handlers and there corresponding WriteHtmlChunked and WriteHtmlViewChunked extension methods.
let indexView =
html() {
head() {
title() { "Oxpecker" }
}
body() {
h1(id="Header") { "Oxpecker" }
p() { "Hello World." }
}
}
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteHtmlViewChunked indexView
}
// or...
let someHandler : EndpointHandler =
// Do stuff
htmlViewChunked indexView
Warning: While being fast at runtime, using many long CE expressions might slow down your project compilation and IDE experience (see the issue), so you might decide to use a different view engine. There are multiple view engines for your choice: Giraffe.ViewEngine, Feliz.ViewEngine, Falco.Markup or you can even write your own! To plug in an external view engine you can write a simple extension:
[<Extension>]
static member WriteMyHtmlView(ctx: HttpContext, htmlView: MyHtmlElement) =
let bytes = htmlView |> convertToBytes
ctx.Response.ContentType <- "text/html; charset=utf-8"
ctx.WriteBytes bytes
// ...
let myHtmlView (htmlView: MyHtmlElement) : EndpointHandler =
fun (ctx: HttpContext) -> ctx.WriteMyHtmlView htmlView
Writing Multipart
The WriteMultipart and WriteMultipartChunked extension methods (and the multipart / multipartChunked handlers) write a multipart/mixed response in which every part has its own headers and body. This is the format consumed by the htmx 4 hx-multipart extension: each part is swapped into the page as soon as it arrives, and part headers such as HX-Retarget, HX-Reswap or HX-Trigger override the request's swap settings for that part only. The wire format matches the multipart-response reference implementation.
Parts are created with the MultipartPart factory members (or directly through the HtmlPart, TextPart, JsonPart and BytesPart constructors):
MultipartPart.Html(view)— anHtmlElement, sent astext/html; charset=utf-8MultipartPart.Text(text)— a string, sent astext/plain; charset=utf-8MultipartPart.Json(value)— a value serialized with the registeredIJsonSerializer(see Writing JSON:System.Text.Jsonwith web defaults unless another serializer or other options are registered), sent asapplication/json; charset=utf-8MultipartPart.Bytes(contentType, data)— raw bytes with the given content type
Every factory and constructor accepts an optional headers sequence of name/value pairs, which is stored as is (no copy) and written after Content-Type in the given order. The header constants from Oxpecker.Htmx can be used for htmx headers. Text with another content type, e.g. CSV, is sent as a Bytes part or with a custom part as shown below.
open System.Linq
open System.Text
open System.Threading
open System.Threading.Tasks
open Oxpecker.Htmx
let statusView = div(id = "status") { "Report ready" }
let reportLink = li() { a(href = "/reports/42") { "Quarterly report" } }
// Buffered: all parts are rendered in memory and the Content-Length header is set
let reportHandler: EndpointHandler =
multipart [
MultipartPart.Html(statusView, headers = [ HxResponseHeader.Retarget, "#status" ])
MultipartPart.Html(
reportLink,
headers = [ HxResponseHeader.Retarget, "#reports"; HxResponseHeader.Reswap, HxSwapMethod.append ]
)
MultipartPart.Json({| ReportId = 42; Status = "done" |}, headers = [ "Content-ID", "result" ])
]
// Streamed: each part is flushed to the client as soon as it is produced (chunked transfer encoding).
// The parts are produced with System.Linq.AsyncEnumerable, which ships with .NET 10.
let progressHandler: EndpointHandler =
fun (ctx: HttpContext) ->
let steps =
AsyncEnumerable
.Range(1, 5)
.Select(fun i (ct: CancellationToken) ->
ValueTask<IMultipartPart>(
task {
do! Task.Delay(500, ct) // ct is ctx.RequestAborted: the delay ends when the client disconnects
return
MultipartPart.Html(
div(id = "progress") { $"Step {i} of 5" },
headers = [ HxResponseHeader.Retarget, "#progress" ]
)
}
))
let parts =
steps.Append(MultipartPart.Text("done", headers = [ HxResponseHeader.Trigger, "done" ]))
ctx.WriteMultipartChunked parts
// Text with another content type is sent as raw bytes
let csvPart =
MultipartPart.Bytes(
"text/csv; charset=utf-8",
Encoding.UTF8.GetBytes "id,name",
headers = [ HxResponseHeader.PartId, "row-1" ]
)
The built-in part types (HtmlPart, TextPart, JsonPart<'T>, BytesPart) implement the IMultipartPart interface, and so can any other content: its single Write member receives the HttpContext (to resolve services, and for the request's cancellation token ctx.RequestAborted) and the response PipeWriter and writes the whole part, i.e. the header lines (each ending with \r\n), an empty line and the body, while the delimiters around the part are written by the framework. A part may also start with the empty line only, but note that htmx expects at least one header line per part, and header values must not contain line breaks. Text is encoded with Encoding.UTF8.GetBytes(text.AsSpan(), writer), raw bytes are copied with writer.Write and a stream with stream.CopyToAsync(writer, ctx.RequestAborted); the writer is flushed after each part:
open System.IO
open System.Text
// A custom part streaming a file from disk
type FilePart(path: string) =
interface IMultipartPart with
member this.Write(ctx, writer) =
Encoding.UTF8.GetBytes("Content-Type: application/pdf\r\nContent-ID: attachment\r\n\r\n".AsSpan(), writer)
|> ignore
task {
use file = File.OpenRead path
do! file.CopyToAsync(writer, ctx.RequestAborted)
}
let attachmentHandler: EndpointHandler =
fun ctx -> ctx.WriteMultipart [ FilePart "report.pdf" ]
Any IAsyncEnumerable<IMultipartPart> works as the source of a streamed response, e.g. a System.Threading.Channels reader or a hand-written enumerator. The stream is enumerated with ctx.RequestAborted, so a producer that awaits with the token it receives stops when the client disconnects (see Cancellation). For HEAD requests the parts are not enumerated at all, only the Content-Type header is set.
Both methods take an optional MultipartSubtype argument. With MultipartSubtype.Mixed (the default) htmx finishes swapping a part before it reads the next one; with MultipartSubtype.Parallel the response is multipart/parallel and swaps start as parts arrive, without waiting for each other:
ctx.WriteMultipartChunked(parts, MultipartSubtype.Parallel)
A fresh boundary (multipart- followed by 32 hex characters) is generated for every response, so part bodies are not scanned for boundary collisions. A response must contain at least one part. For the built-in parts, header names must be valid HTTP tokens (letters, digits and !#$%&'*+-.^_`|~, no spaces or colons), header values as well as the content type must not contain control characters such as line breaks, no header line (Name: value, including Content-Type) may exceed 998 bytes, and Content-Type must be given as the content type rather than among the headers; an ArgumentException is thrown otherwise.
Cancellation
All response writing methods (WriteBytes, WriteText, WriteJson, WriteJsonChunked, WriteHtmlView, WriteHtmlViewChunked, WriteHtmlChunked, WriteMultipart, WriteMultipartChunked, WriteStream, WriteFileStream) and the corresponding endpoint handlers observe ctx.RequestAborted, the token that ASP.NET Core cancels when the client disconnects (or when a request timeout expires). It is passed to every write and flush, to GetAsyncEnumerator when a stream of JSON values, HTML elements or multipart parts is written, and a multipart part receives the HttpContext whose RequestAborted it passes to its own writes, so a producer that awaits with the token it receives stops as soon as the client is gone:
open System.Linq
open System.Threading
open System.Threading.Tasks
let streamingJson: EndpointHandler =
fun (ctx: HttpContext) ->
let values =
AsyncEnumerable
.Range(1, 10)
.Select(fun i (ct: CancellationToken) ->
ValueTask<{| Id: int |}>(
task {
do! Task.Delay(500, ct) // ct is ctx.RequestAborted: the delay ends when the client disconnects
return {| Id = i |}
}
))
ctx.WriteJsonChunked values
When the request is aborted the writing method fails with an OperationCanceledException (usually its subtype TaskCanceledException), which is not swallowed: the enumerator is disposed, nothing more is written and the exception reaches your exception middleware. Every method also checks the token before it starts, so a request that has already been aborted fails before anything is enumerated, rendered or written, also for a HEAD request or when a source or part ignores the token it is given, and the chunked methods check it again after every MoveNextAsync and before each element or part, so a source that ignores the token neither renders anything nor completes the response once the request has been aborted. Default.exceptionMiddleware recognises this case, logs it at Debug level and, if the response has not started and its BodyWriter reports no unflushed bytes, answers with the status code 499 Client Closed Request, otherwise it aborts the connection so that queued output is not sent and a truncated body is not completed as a successful response (see Error Handling); a custom middleware should treat an OperationCanceledException the same way while ctx.RequestAborted.IsCancellationRequested is true. A request cancelled by the request-timeouts middleware is answered with 504 Gateway Timeout instead when an empty response can be completed safely; the IHttpRequestTimeoutFeature exposed by that middleware, whose RequestTimeoutToken fires on a timeout, tells the two cases apart.
BindJson and BindForm read the request body with the same token and throw an OperationCanceledException rather than a ModelBindException when the client disconnects.
Streaming
Sometimes a large file or block of data has to be send to a client and in order to avoid loading the entire data into memory a Oxpecker web application can use streaming to send a response in a more efficient way.
The WriteStream extension method and the streamData endpoint handler can be used to stream an object of type Stream to a client. They take ownership of the stream and dispose it on every path, also when a precondition fails, the range is invalid, an error occurs or the request is aborted.
Both functions accept the following parameters:
enableRangeProcessing: If true a client can request a sub range of data to be streamed (useful when a client wants to continue streaming after a paused download, or when internet connection has been lost, etc.)stream: The stream object to be returned to the client.eTag: Entity header tag used for conditional requests (see Conditional Requests).lastModified: Last modified timestamp used for conditional requests (see Conditional Requests).
If the eTag or lastModified timestamp are set then both functions will also set the ETag and/or Last-Modified HTTP headers during the response:
let someStream : Stream = ...
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteStream(
true, // enableRangeProcessing
someStream,
None, // eTag
None) // lastModified
}
// or...
let someHandler : EndpointHandler =
// Do stuff
streamData
true // enableRangeProcessing
someStream
None // eTag
None // lastModified
In most cases a web application will want to stream a file directly from the local file system. In this case you can use the WriteFileStream extension method or the streamFile http handler, which are both the same as WriteStream and streamData except that they accept a relative or absolute filePath instead of a Stream object:
let someHandler : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteFileStream(
true, // enableRangeProcessing
"large-file.zip",
None, // eTag
None) // lastModified
}
// or...
let someHandler : EndpointHandler =
// Do stuff
streamFile
true // enableRangeProcessing
"large-file.zip"
None // eTag
None // lastModified
All streaming functions in Oxpecker will also validate conditional HTTP headers, including the If-Range HTTP header if enableRangeProcessing has been set to true. Streaming stops through ctx.RequestAborted when the client disconnects; the resulting OperationCanceledException is not swallowed and reaches your exception middleware (see Cancellation).
Redirection
The redirectTo (location: string) (permanent: bool) endpoint handler can be used to redirect a client to a different location when handling an incoming web request:
let webApp = [
route "/new" <| text "Hello World"
route "/old" <| redirectTo "https://myserver.com/new" true
]
Please note that if the permanent flag is set to true then the Oxpecker web application will send a 301 HTTP status code to browsers which will tell them that the redirection is permanent. This often leads to browsers cache the information and not hit the deprecated URL a second time any more. If this is not desired then please set permanent to false (302 HTTP status code) in order to guarantee that browsers will continue hitting the old URL before redirecting to the (temporary) new one.
Response Caching
ASP.NET Core comes with a standard Response Caching Middleware which works out of the box with Oxpecker.
If you are not already using one of the two ASP.NET Core meta packages (Microsoft.AspNetCore.App or Microsoft.AspNetCore.All) then you will have to add an additional reference to the Microsoft.AspNetCore.ResponseCaching NuGet package.
After adding the NuGet package you need to register the response caching middleware inside your application's startup code before registering Oxpecker:
let configureServices (services : IServiceCollection) =
services
.AddResponseCaching() // <-- Here the order doesn't matter
.AddOxpecker() // This is just registering dependencies
|> ignore
let configureApp (app : IApplicationBuilder) =
app
.UseStaticFiles() // Optional if you use static files
.UseAuthentication() // Optional if you use authentication
.UseResponseCaching() // <-- Before UseOxpecker webApp
.UseOxpecker webApp
After setting up the ASP.NET Core response caching middleware you can use Oxpecker's response caching http handlers to add response caching to your routes:
// A test handler which generates a new GUID on every request
let generateGuidHandler : EndpointHandler =
fun ctx -> ctx.WriteText(Guid.NewGuid().ToString())
let cacheHeader = Some <| CacheControlHeaderValue(MaxAge = TimeSpan.FromSeconds(30), Public = true)
let webApp = [
route "/route1" (responseCaching cacheHeader None None >=> generateGuidHandler)
route "/route2" (noResponseCaching >=> generateGuidHandler)
]
Requests to /route1 can be cached for up to 30 seconds whilst requests to /route2 have response caching completely disabled.
Note: if you test the above code with Postman then make sure you disable the No-Cache feature in Postman in order to test the correct caching behaviour.
Oxpecker offers in total 2 endpoint handlers which can be used to configure response caching for an endpoint.
In the above example we used the noResponseCaching endpoint handler to completely disable response caching on the client and on any proxy server. The noResponseCaching endpoint handler will send the following HTTP headers in the response:
Cache-Control: no-store, no-cache
Pragma: no-cache
Expires: -1
The responseCaching endpoint handler will enable response caching on the client and/or on proxy servers. The
CacheControlHeaderValue object will control the Cache-Control directive.
Public = true means that not only the client is allowed to cache a response for the given cache duration, but also any intermediary proxy server as well as the ASP.NET Core middleware. This is useful for HTTP GET/HEAD endpoints which do not hold any user specific data, authentication data or any cookies and where the response data doesn't change frequently.
Public = false which means that only the end client is allowed to store the response for the given cache duration. Proxy servers and the ASP.NET Core response caching middleware must not cache the response.
The responseCaching endpoint handler has two additional parameters: vary and varyByQueryKeys.
Vary
The vary parameter specifies which HTTP request headers must be respected to vary the cached response. For example if an endpoint returns a different response (Content-Type) based on the client's Accept header (content negotiation) then the Accept header must also be considered when returning a response from the cache. The same applies if the web server has response compression enabled. If a response varies based on the client's accepted compression algorithm then the cache must also respect the client's Accept-Encoding HTTP header when serving a response from the cache.
let cacheHeader = Some <| CacheControlHeaderValue(MaxAge = TimeSpan.FromSeconds(30), Public = true)
// Cache for 30 seconds without any vary headers
publicResponseCaching cacheHeader None None
// Cache for 30 seconds with Accept and Accept-Encoding as vary headers
publicResponseCaching cacheHeader (Some "Accept, Accept-Encoding") None
VaryByQueryKeys
The ASP.NET Core response caching middleware offers one more additional feature which is not part of the response's HTTP headers. By default, if a route is cacheable then the middleware will try to return a cached response even if the query parameters were different.
For example if a request to /foo/bar has been cached, then the cached version will also be returned if a request is made to /foo/bar?query1=a or /foo/bar?query1=a&query2=b.
Sometimes this is not desired and the VaryByQueryKeys feature lets the middleware vary its cached responses based on a request's query keys.
The generic responseCaching endpoint handler is the most basic response caching handler which can be used to configure custom response caching handlers as well as make use of the VaryByQueryKeys feature:
responseCaching
(Some (CacheControlHeaderValue(MaxAge = TimeSpan.FromSeconds(30)))
(Some "Accept, Accept-Encoding")
(Some [| "query1"; "query2" |])
The first parameter is of type CacheControlHeaderValue.
The second parameter is an string option which defines the vary parameter.
The third and last parameter is a string[] option which defines an optional list of query parameter values which must be used to vary a cached response by the ASP.NET Core response caching middleware. Please be aware that this feature only applies to the ASP.NET Core response caching middleware and will not be respected by any intermediate proxy servers.
Response Compression
ASP.NET Core has its own Response Compression Middleware which works out of the box with Oxpecker. There's no additional functionality or http handlers required in order to make it work with Oxpecker web applications.
Static files
Oxpecker uses ASP.NET Core's built-in static file support. Place static assets such as CSS, JavaScript, and images in the application's wwwroot directory.
For assets known at build time, use MapStaticAssets. It serves the assets as endpoints and provides build-time compression, fingerprinting, and optimized caching:
let app = builder.Build()
app.UseRouting() |> ignore
app.MapStaticAssets() |> ignore
app.UseOxpecker(webApp) |> ignore
app.Run()
Use UseStaticFiles instead when files are generated at runtime, served from outside the web root, or require features provided by static file middleware:
let app = builder.Build()
app
.UseStaticFiles()
.UseRouting()
.UseOxpecker(webApp)
|> ignore
app.Run()
MapStaticAssets is preferred for most applications. The two approaches can also be combined when an application serves both build-time assets and files that require static file middleware.
Testing
Integration testing of an Oxpecker application follows the concept of ASP.NET Core testing. You can check out the examples of tests in this repository itself: Oxpecker.Tests
To simulate a client disconnect with TestServer, send the request with HttpCompletionOption.ResponseHeadersRead and dispose the HttpResponseMessage while the response is still being streamed: the server-side ctx.RequestAborted is cancelled. Cancelling the HttpClient token only reaches ctx.RequestAborted while the response headers have not been received yet.
Changes from Previous Version
Adoption Assessment
Assess this package against its supported frameworks, license, maintenance signals and dependency graph before making it a project standard.
Related Guides & Workflows
Learn how to evaluate, integrate, or audit this package in your .NET project.