Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion frameworks/oxpecker/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /app
COPY . .
COPY frameworks/oxpecker/ .
# Drop any host build output that came along with the copy; the SDK image has
# to restore for linux-x64 itself.
RUN rm -rf bin obj

RUN dotnet publish -c Release -o out

FROM mcr.microsoft.com/dotnet/aspnet:10.0
Expand Down
132 changes: 52 additions & 80 deletions frameworks/oxpecker/Handlers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,8 @@ open System.Buffers
open System.Globalization
open System.IO
open System.Text

open HttpArena.Services

open Microsoft.AspNetCore.Http

open Oxpecker

/// Reads an int query parameter through Oxpecker's query accessor, falling
Expand All @@ -20,8 +17,7 @@ let private queryInt (ctx: HttpContext) (key: string) (fallback: int) =
match Int32.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture) with
| true, value -> value
| _ -> fallback
| None ->
fallback
| None -> fallback

let private queryFloat (ctx: HttpContext) (key: string) (fallback: float) =
match ctx.TryGetQueryValue key with
Expand All @@ -31,10 +27,6 @@ let private queryFloat (ctx: HttpContext) (key: string) (fallback: float) =
| _ -> fallback
| None -> fallback

let private dbUnavailable (ctx: HttpContext) =
ctx.SetStatusCode 500
ctx.WriteText "DB not available"

// ── Connection profiles ────────────────────────────────────────────────────

let pipeline: EndpointHandler = text "ok"
Expand All @@ -55,6 +47,7 @@ let baselineWithBody: EndpointHandler =
task {
use reader = new StreamReader(ctx.Request.Body)
let! body = reader.ReadToEndAsync()

let fromBody =
match Int32.TryParse(body, NumberStyles.Integer, CultureInfo.InvariantCulture) with
| true, value -> value
Expand Down Expand Up @@ -91,103 +84,82 @@ let upload: EndpointHandler =
let json (count: int) : EndpointHandler =
fun ctx ->
let multiplier = queryInt ctx "m" 1
match Dataset.getItems count multiplier with
| Some response ->
ctx.WriteJsonChunked response
| None ->
ctx.SetStatusCode 500
ctx.WriteText "Dataset not loaded"
let response = Dataset.getItems count multiplier
ctx.WriteJsonChunked response

// ── Database profiles ──────────────────────────────────────────────────────

/// GET /async-db — Postgres range query over the unindexed price column.
let asyncDb: EndpointHandler =
fun ctx ->
if not Items.isAvailable then
dbUnavailable ctx
else
let minPrice = queryFloat ctx "min" 10.0
let maxPrice = queryFloat ctx "max" 50.0
let limit = queryInt ctx "limit" 50
task {
let! response = Items.query minPrice maxPrice limit
return! ctx.WriteJsonChunked response
}
let minPrice = queryFloat ctx "min" 10.0
let maxPrice = queryFloat ctx "max" 50.0
let limit = queryInt ctx "limit" 50
task {
let! response = Items.query minPrice maxPrice limit
return! ctx.WriteJsonChunked response
}

/// GET /crud/items — paginated list by category.
let crudList: EndpointHandler =
fun ctx ->
if not Items.isAvailable then
dbUnavailable ctx
else
let category = ctx.TryGetQueryValue "category" |> Option.defaultValue ""
let page = queryInt ctx "page" 0
let limit = queryInt ctx "limit" 0
task {
let! response = Items.list category page limit
return! ctx.WriteJsonChunked response
}
let category = ctx.TryGetQueryValue "category" |> Option.defaultValue ""
let page = queryInt ctx "page" 0
let limit = queryInt ctx "limit" 0
task {
let! response = Items.list category page limit
return! ctx.WriteJsonChunked response
}

/// GET /crud/items/{id} — cache-aside single-item read, reporting the cache
/// outcome through X-Cache.
let crudRead (id: int) : EndpointHandler =
fun ctx ->
if not Items.isAvailable then
dbUnavailable ctx
else
task {
match! Items.read id with
| None ->
ctx.SetStatusCode 404
| Some result ->
ctx.SetHttpHeader("X-Cache", (if result.CacheHit then "HIT" else "MISS"))
match result.Value with
| TypedItem item ->
return! ctx.WriteJson item
| SerializedItem cached ->
// Already JSON on the Redis path — write the cached bytes
// back rather than round-tripping them through the serializer.
ctx.SetContentType "application/json; charset=utf-8"
return! ctx.WriteBytes(Encoding.UTF8.GetBytes cached)
}
task {
match! Items.read id with
| ValueNone ->
ctx.SetStatusCode 404
| ValueSome result ->
ctx.SetHttpHeader("X-Cache", if result.CacheHit then "HIT" else "MISS")
match result.Value with
| TypedItem item ->
return! ctx.WriteJsonChunked item
| SerializedItem cached ->
// Already JSON on the Redis path — write the cached bytes
// back rather than round-tripping them through the serializer.
ctx.SetContentType "application/json"
return! ctx.WriteBytes(Encoding.UTF8.GetBytes cached)
}

/// POST /crud/items — create (upsert on id conflict).
let crudCreate: EndpointHandler =
fun ctx ->
if not Items.isAvailable then
dbUnavailable ctx
else
task {
let! input = ctx.BindJson<CrudItemInput>()
let! created = Items.create input
ctx.SetStatusCode 201
return! ctx.WriteJson created
}
task {
let! input = ctx.BindJson<CrudItemInput>()
let! created = Items.create input
ctx.SetStatusCode 201
return! ctx.WriteJsonChunked created
}

/// PUT /crud/items/{id} — update and invalidate the cached entry.
let crudUpdate (id: int) : EndpointHandler =
fun ctx ->
if not Items.isAvailable then
dbUnavailable ctx
else
task {
let! input = ctx.BindJson<CrudItemInput>()
match! Items.update id input with
| None ->
ctx.SetStatusCode 404
| Some updated ->
return! ctx.WriteJson updated
}
task {
let! input = ctx.BindJson<CrudItemInput>()
match! Items.update id input with
| None ->
ctx.SetStatusCode 404
| Some updated ->
return! ctx.WriteJsonChunked updated
}


// ── Template profile ───────────────────────────────────────────────────────

/// GET /fortunes — DB query plus an Oxpecker.ViewEngine render.
let fortunes: EndpointHandler =
fun ctx ->
if not Items.isAvailable then
dbUnavailable ctx
else
task {
let! rows = Fortunes.getRows ()
return! ctx.WriteHtmlViewChunked(Views.fortunes rows)
}
task {
let! rows = Fortunes.getRows ()
return! ctx.WriteHtmlViewChunked(Views.fortunes rows)
}
4 changes: 1 addition & 3 deletions frameworks/oxpecker/HttpArena.Oxpecker.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ServerGarbageCollection>true</ServerGarbageCollection>
<EnableDefaultContentItems>false</EnableDefaultContentItems>
</PropertyGroup>

<ItemGroup>
Expand All @@ -18,8 +17,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Oxpecker" Version="2.1.0" />
<PackageReference Include="Oxpecker.ViewEngine" Version="2.0.1" />
<PackageReference Include="Oxpecker" Version="2.1.1" />
<PackageReference Include="Npgsql" Version="10.0.3" />
<PackageReference Include="StackExchange.Redis" Version="3.0.17" />
</ItemGroup>
Expand Down
58 changes: 26 additions & 32 deletions frameworks/oxpecker/Program.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,15 @@ module HttpArena.Program
open System
open System.IO
open System.Security.Cryptography.X509Certificates
open System.Threading.Tasks

open HttpArena.Services

open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.Server.Kestrel.Core
open Microsoft.AspNetCore.StaticFiles
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.FileProviders
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.Logging
open Microsoft.Extensions.Primitives

open Oxpecker


Expand Down Expand Up @@ -100,36 +95,35 @@ let main args =

let app = builder.Build()

// The Services modules hold their state in module-level bindings, which
// .NET initializes on first touch. Reading them here loads the dataset and
// opens the Postgres/Redis pools at startup instead of during the first
// request — and turns a missing dataset or DATABASE_URL into a startup
// message rather than mystery 500s.
if not Dataset.isAvailable then
Console.Error.WriteLine "dataset not loaded; /json will answer 500"

if not Database.isAvailable then
Console.Error.WriteLine "DATABASE_URL not configured; DB endpoints will answer 500"

app.UseResponseCompression() |> ignore

// Static assets are served straight off the mounted directory by ASP.NET
// Core's static file middleware — every request reads the file from disk,
// and the response compression middleware above handles the compressible
// types. Registered before routing so /static/* never reaches Oxpecker,
// while a missing file falls through to the router's 404.
let staticRoot = envPath "STATIC_PATH" "/data/static"

if Directory.Exists staticRoot then
app.UseStaticFiles(
StaticFileOptions(
FileProvider = new PhysicalFileProvider(staticRoot),
RequestPath = PathString "/static"
)
// Served straight out of the directory the profile mounts, rather than a
// copy taken at image build. MapStaticAssets, which this used before,
// resolves assets through a manifest the SDK generates at publish time from
// wwwroot, so the container held two copies of the corpus and answered from
// the one the harness cannot touch: replacing a file in the mounted
// directory never reached a response.
//
// UseStaticFiles reads the file per request through the file provider, so
// what is served follows the mounted directory. Compression stays with the
// response compression middleware registered above.
let staticContentTypes = FileExtensionContentTypeProvider()
staticContentTypes.Mappings[".webp"] <- "image/webp"
staticContentTypes.Mappings[".woff2"] <- "font/woff2"

app.UseStaticFiles(
StaticFileOptions(
FileProvider = new PhysicalFileProvider("/data/static"),
RequestPath = PathString "/static",
ContentTypeProvider = staticContentTypes,
ServeUnknownFileTypes = false
)
|> ignore
)
|> ignore

app.UseRouting() |> ignore

app.UseRouting().UseOxpecker(endpoints) |> ignore
app.UseOxpecker endpoints |> ignore

app.Run()
0
5 changes: 3 additions & 2 deletions frameworks/oxpecker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ F# web framework built on ASP.NET Core endpoint routing, running on .NET 10 with
| `/crud/items` | POST | Create item via INSERT with ON CONFLICT upsert, returns 201 |
| `/crud/items/{id}` | PUT | Update item and invalidate cache entry |
| `/fortunes` | GET | DB query + HTML table rendered with Oxpecker.ViewEngine |
| `/static/*` | GET | Serves files from `/data/static` via ASP.NET Core's static file middleware |
| `/static/*` | GET | Serves the static assets straight from the mounted `/data/static` |

## Notes

Expand All @@ -36,7 +36,8 @@ F# web framework built on ASP.NET Core endpoint routing, running on .NET 10 with
- HTTP/1.1 on port 8080, HTTP/1+2+3 on port 8443 (TCP **and** UDP for QUIC), h1+TLS on 8081, prior-knowledge h2c on 8082
- TLS certs from `$TLS_CERT` / `$TLS_KEY` (default `/certs/server.crt` + `/certs/server.key`); TLS listeners skipped when absent
- HTTP/2 tuned: 256 max streams per connection, 2 MB initial connection window, 1 MB stream window
- `AddResponseCompression()` + `UseResponseCompression()` for `json-comp`; `UseStaticFiles` reads static bodies from disk on every request
- `AddResponseCompression()` + `UseResponseCompression()` for `json-comp`
- `UseStaticFiles` for `/static/*` with a `PhysicalFileProvider` on `/data/static`, so what is served follows the directory the harness mounts rather than a build-time copy in `wwwroot` (see #1268); `.webp` and `.woff2` are added to the content type provider, and compression is left to the response compression middleware
- `/upload` drains the body through a 64 KB pooled buffer (`ArrayPool<byte>.Shared`)
- Postgres pooled via `NpgsqlDataSource` with auto-prepare; crud read cache is Redis when `REDIS_URL` is set, else in-process `MemoryCache`
- Logging disabled (`ClearProviders()`); `ServerGarbageCollection` enabled
Expand Down
6 changes: 1 addition & 5 deletions frameworks/oxpecker/Services/Database.fs
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,5 @@ let postgres = openPostgres ()
/// uses Redis as a shared cache; otherwise it uses an in-process MemoryCache.
let redis = openRedis ()

/// True once the Postgres pool is configured; the DB-backed endpoints answer
/// 500 without it.
let isAvailable = postgres.IsSome

/// Opens a pooled command. Only valid once `isAvailable` is true.
/// Opens a pooled command.
let command (sql: string) = postgres.Value.CreateCommand sql
47 changes: 20 additions & 27 deletions frameworks/oxpecker/Services/Dataset.fs
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,26 @@ let private items =
| value -> value

if File.Exists path then
JsonSerializer.Deserialize<Item[]>(File.ReadAllText path, Serialization.options) |> Some
JsonSerializer.Deserialize<Item[]>(File.ReadAllText path, Serialization.options)
else
None
null

let isAvailable = items.IsSome

/// Returns the first `count` dataset items with their total computed as
/// price * quantity * `multiplier`, or None when no dataset is loaded.
let getItems (count: int) (multiplier: int) =
match items with
| None -> None
| Some source ->
let count = Math.Clamp(count, 0, source.Length)
let processed = Array.zeroCreate<ProcessedItem> count

for i in 0 .. count - 1 do
let item = source[i]
processed[i] <- {
Id = item.Id
Name = item.Name
Category = item.Category
Price = item.Price
Quantity = item.Quantity
Active = item.Active
Tags = item.Tags
Rating = item.Rating
Total = int64 item.Price * int64 item.Quantity * int64 multiplier
}

Some { Items = processed; Count = count }
let count = Math.Clamp(count, 0, items.Length)
let processed = Array.zeroCreate<ProcessedItem> count

for i in 0 .. count - 1 do
let item = items[i]
processed[i] <- {
Id = item.Id
Name = item.Name
Category = item.Category
Price = item.Price
Quantity = item.Quantity
Active = item.Active
Tags = item.Tags
Rating = item.Rating
Total = item.Price * item.Quantity * multiplier
}

{ JsonResponse.Items = processed; Count = count }
Loading