Skip to content

Commit

Permalink
Response writing docs and refactorings
Browse files Browse the repository at this point in the history
  • Loading branch information
Lanayx committed Feb 7, 2024
1 parent a4eca84 commit dcfde12
Show file tree
Hide file tree
Showing 9 changed files with 250 additions and 67 deletions.
9 changes: 9 additions & 0 deletions src/Oxpecker/Handlers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ module ResponseHandlers =
ctx.Response.Redirect(location, permanent)
Task.CompletedTask

/// <summary>
/// Writes a byte array to the body of the HTTP response and sets the HTTP Content-Length header accordingly.
/// </summary>
/// <param name="data">The byte array to be send back to the client.</param>
/// <param name="ctx"></param>
/// <returns>An Oxpecker <see cref="EndpointHandler" /> function which can be composed into a bigger web application.</returns>
let bytes (data: byte[]) : EndpointHandler =
fun (ctx: HttpContext) -> ctx.WriteBytes data

/// <summary>
/// Writes an UTF-8 encoded string to the body of the HTTP response and sets the HTTP Content-Length header accordingly, as well as the Content-Type header to text/plain.
/// </summary>
Expand Down
33 changes: 17 additions & 16 deletions src/Oxpecker/HttpContextExtensions.fs
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,12 @@ type HttpContextExtensions() =
static member GetHostingEnvironment(ctx: HttpContext) = ctx.GetService<IWebHostEnvironment>()

/// <summary>
/// Gets an instance of <see cref="Oxpecker.Json.ISerializer"/> from the request's service container.
/// Gets an instance of <see cref="Oxpecker.Serializers.IJsonSerializer"/> from the request's service container.
/// </summary>
/// <returns>Returns an instance of <see cref="Oxpecker.Json.ISerializer"/>.</returns>
/// <returns>Returns an instance of <see cref="Oxpecker.Serializers.IJsonSerializer"/>.</returns>
[<Extension>]
static member GetJsonSerializer(ctx: HttpContext) : Json.ISerializer = ctx.GetService<Json.ISerializer>()
static member GetJsonSerializer(ctx: HttpContext) : Serializers.IJsonSerializer =
ctx.GetService<Serializers.IJsonSerializer>()

/// <summary>
/// Sets the HTTP status code of the response.
Expand Down Expand Up @@ -161,15 +162,18 @@ type HttpContextExtensions() =
/// <returns>Task of writing to the body of the response.</returns>
[<Extension>]
static member WriteBytes(ctx: HttpContext, bytes: byte[]) =
let canIncludeContentLengthHeader =
match ctx.Response.StatusCode, ctx.Request.Method with
| statusCode, _ when statusCode |> is1xxStatusCode || statusCode = 204 -> false
| statusCode, method when method = HttpMethods.Connect && statusCode |> is2xxStatusCode -> false
| _ -> true
let is205StatusCode = ctx.Response.StatusCode = 205
if canIncludeContentLengthHeader then
let contentLength = if is205StatusCode then 0 else bytes.Length
ctx.SetHttpHeader(HeaderNames.ContentLength, string contentLength)
let statusCode = ctx.Response.StatusCode
let skipContentLengthHeader =
is1xxStatusCode statusCode
|| statusCode = StatusCodes.Status204NoContent
|| (ctx.Request.Method = HttpMethods.Connect && is2xxStatusCode statusCode)
if not skipContentLengthHeader then
let contentLength =
if statusCode = StatusCodes.Status205ResetContent then
0L
else
bytes.LongLength
ctx.Response.ContentLength <- contentLength
if ctx.Request.Method <> HttpMethods.Head then
ctx.Response.Body.WriteAsync(bytes, 0, bytes.Length)
else
Expand Down Expand Up @@ -223,7 +227,6 @@ type HttpContextExtensions() =
let serializer = ctx.GetJsonSerializer()
serializer.Serialize(value, ctx, true)


/// <summary>
/// <para>Compiles a `Oxpecker.OxpeckerViewEngine.Builder.HtmlElement` object to a HTML view and writes the output to the body of the HTTP response.</para>
/// <para>It also sets the HTTP header `Content-Type` to `text/html` and sets the `Content-Length` header accordingly.</para>
Expand All @@ -235,9 +238,7 @@ type HttpContextExtensions() =
static member WriteHtmlView(ctx: HttpContext, htmlView: HtmlElement) =
let bytes = Render.toHtmlDocBytes htmlView
ctx.Response.ContentType <- "text/html; charset=utf-8"
ctx.Response.ContentLength <- bytes.LongLength
ctx.Response.Body.WriteAsync(bytes, 0, bytes.Length)

ctx.WriteBytes bytes

/// <summary>
/// Executes and ASP.NET Core IResult. Note that in most cases the response will be chunked.
Expand Down
3 changes: 1 addition & 2 deletions src/Oxpecker/Middleware.fs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ type ApplicationBuilderExtensions() =
/// </summary>
[<Extension>]
static member UseOxpecker(builder: IApplicationBuilder, endpoints: Endpoint seq) =

builder.UseEndpoints(fun builder -> builder.MapOxpeckerEndpoints endpoints)

[<Extension>]
Expand All @@ -27,5 +26,5 @@ type ServiceCollectionExtensions() =
/// <returns>Returns an <see cref="Microsoft.Extensions.DependencyInjection.IServiceCollection"/> builder object.</returns>
[<Extension>]
static member AddOxpecker(svc: IServiceCollection) =
svc.TryAddSingleton<Json.ISerializer>(fun sp -> SystemTextJson.Serializer() :> Json.ISerializer)
svc.TryAddSingleton<Serializers.IJsonSerializer>(SystemTextJson.Serializer())
svc
8 changes: 4 additions & 4 deletions src/Oxpecker/Oxpecker.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@
<Authors>Vladimir Shchur, F# community</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Version>0.6.1</Version>
<PackageVersion>0.6.1</PackageVersion>
<PackageReleaseNotes>Changed preconditional handler to middleware</PackageReleaseNotes>
<Version>0.7.0</Version>
<PackageVersion>0.7.0</PackageVersion>
<PackageReleaseNotes>Added bytes handler</PackageReleaseNotes>
</PropertyGroup>
<PropertyGroup>
<WarningLevel>3</WarningLevel>
Expand All @@ -32,7 +32,7 @@
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<Compile Include="Helpers.fs" />
<Compile Include="Json.fs" />
<Compile Include="Serializers.fs" />
<Compile Include="ModelParser.fs" />
<Compile Include="HttpContextExtensions.fs" />
<Compile Include="DateTimeExtensions.fs" />
Expand Down
5 changes: 4 additions & 1 deletion src/Oxpecker/Preconditional.fs
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ type PreconditionExtensions() =
/// <param name="next"></param>
/// <param name="ctx"></param>
/// <returns>An Oxpecker <see cref="HttpHandler" /> function which can be composed into a bigger web application.</returns>
let validatePreconditions (eTag: EntityTagHeaderValue option) (lastModified: DateTimeOffset option) : EndpointMiddleware =
let validatePreconditions
(eTag: EntityTagHeaderValue option)
(lastModified: DateTimeOffset option)
: EndpointMiddleware =
fun (next: EndpointHandler) (ctx: HttpContext) ->
task {
match ctx.ValidatePreconditions(eTag, lastModified) with
Expand Down
228 changes: 195 additions & 33 deletions src/Oxpecker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ An in depth functional reference to all of Oxpecker's default features.
- [HTTP Headers](#http-headers)
- [HTTP Verbs](#http-verbs)
- [HTTP Status Codes](#http-status-codes)
- [IResult Integration](#iresult-integration)
- [Routing](#routing)
- [Model Binding](#model-binding)
- [File Upload](#file-upload)
- [Authentication and Authorization](#authentication-and-authorization)
- [Conditional Requests](#conditional-requests)
- [Response Writing](#response-writing)

## Fundamentals

Expand Down Expand Up @@ -612,38 +612,6 @@ let someHandler : EndpointHandler =
>=> text "Hello World"
```

### IResult integration

If you only use JSON for communication and like what ASP.NET core IResult offers, you might be please to know that Oxpecker supports that as well. You can simplify returning responses together with status codes using `Microsoft.AspNetCore.Http.TypedResults`:

```fsharp
open Oxpecker
open type Microsoft.AspNetCore.Http.TypedResults
[<CLIMutable>]
type Person = {
FirstName: string
LastName: string
}
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()
]
```
The `%` operator is used to convert `IResult` to `EndpointHandler`. You can also do conversion inside EndpointHandler using `.Write` extension method:

```fsharp
let myHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.Write <| TypedResults.Ok johnDoe
```
### 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.
Expand Down Expand Up @@ -1204,3 +1172,197 @@ let webApp = [
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[])` extension method and the `bytes (data: byte[])` endpoint handler both write a `byte array` to the response stream of the HTTP request:

```fsharp
let someHandler (data: byte[]) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteBytes data
}
// or...
let someHandler (data: byte[]) : 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 it's `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:

```fsharp
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:

```fsharp
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:

```fsharp
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`:

```fsharp
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 `Serializers.IJsonSerializer` interface to plug in custom JSON serializer.

```fsharp
let configureServices (services : IServiceCollection) =
// First register all default Oxpecker dependencies
services.AddOxpecker() |> ignore
// Now register custom serializer
services.AddSingleton<Serializers.IJsonSerializer>(CustomSerializer()) |> ignore
// or use default STJ serializer, but with different options
services.AddSingleton<Serializers.IJsonSerializer>(
SystemTextJson.Serializer(specificOptions)) |> ignore
```

#### 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`:

```fsharp
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:

```fsharp
let myHandler : EndpointHandler =
fun (ctx: HttpContext) ->
ctx.Write <| TypedResults.Ok johnDoe
```

#### Writing HTML Strings

The `WriteHtmlString (html: string)` extension method and the `htmlString (html: string)` endpoint handler are both equivalent to [writing strings](#writing-strings) except that they will also set the `Content-Type` header to `text/html`:

```fsharp
let someHandler (dataObj: obj) : EndpointHandler =
fun (ctx: HttpContext) ->
task {
// Do stuff
return! ctx.WriteHtmlString "<html><head></head><body>Hello World</body></html>"
}
// or...
let someHandler (dataObj: obj) : EndpointHandler =
// Do stuff
htmlString "<html><head></head><body>Hello World</body></html>"
```

#### Writing HTML Views

Oxpecker comes with its own extremely powerful view engine for functional developers (see [Oxpecker View Engine](https://github.com/Lanayx/Oxpecker/blob/develop/src/Oxpecker.ViewEngine/README.md)). 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`:

```fsharp
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
```

**Warning**: While being fast at runtime, using many long CE expressions might slow down your project compilation and IDE experience (see [the issue](https://github.com/Lanayx/Oxpecker/issues/5)), 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:
```fsharp
[<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
```

Loading

0 comments on commit dcfde12

Please sign in to comment.