Repository: pimbrouwers/Falco Branch: master Commit: 3b8475ac1ed7 Files: 101 Total size: 663.8 KB Directory structure: gitextract_7tuo7p7z/ ├── .editorconfig ├── .github/ │ └── workflows/ │ └── build.yml ├── .gitignore ├── Build.ps1 ├── CHANGELOG.md ├── CNAME ├── Falco.sln ├── LICENSE ├── README.md ├── docs/ │ ├── CNAME │ ├── docs/ │ │ ├── authentication.html │ │ ├── cross-site-request-forgery.html │ │ ├── deployment.html │ │ ├── example-basic-rest-api.html │ │ ├── example-dependency-injection.html │ │ ├── example-external-view-engine.html │ │ ├── example-hello-world-mvc.html │ │ ├── example-hello-world.html │ │ ├── example-htmx.html │ │ ├── example-open-api.html │ │ ├── get-started.html │ │ ├── host-configuration.html │ │ ├── index.html │ │ ├── markup.html │ │ ├── migrating-from-v4-to-v5.html │ │ ├── request.html │ │ ├── response.html │ │ └── routing.html │ ├── index.html │ ├── prism.css │ ├── prism.js │ ├── style.css │ └── tachyons.css ├── documentation/ │ ├── authentication.md │ ├── cross-site-request-forgery.md │ ├── deployment.md │ ├── example-basic-rest-api.md │ ├── example-dependency-injection.md │ ├── example-external-view-engine.md │ ├── example-hello-world-mvc.md │ ├── example-hello-world.md │ ├── example-htmx.md │ ├── example-open-api.md │ ├── get-started.md │ ├── host-configuration.md │ ├── markup.md │ ├── migrating-from-v4-to-v5.md │ ├── migrating-from-v5-to-v6.md │ ├── readme.md │ ├── request.md │ ├── response.md │ └── routing.md ├── examples/ │ ├── BasicRestApi/ │ │ ├── BasicRestApi.fs │ │ ├── BasicRestApi.fsproj │ │ └── appsettings.json │ ├── DependencyInjection/ │ │ ├── DependencyInjection.fs │ │ └── DependencyInjection.fsproj │ ├── ExternalViewEngine/ │ │ ├── ExternalViewEngine.fs │ │ └── ExternalViewEngine.fsproj │ ├── Falco.Examples.sln │ ├── HelloWorld/ │ │ ├── HelloWorld.fs │ │ └── HelloWorld.fsproj │ ├── HelloWorldMvc/ │ │ ├── HelloWorldMvc.fs │ │ ├── HelloWorldMvc.fsproj │ │ ├── appsettings.json │ │ └── wwwroot/ │ │ └── style.css │ ├── Htmx/ │ │ ├── Htmx.fs │ │ ├── Htmx.fsproj │ │ └── appsettings.json │ └── OpenApi/ │ ├── OpenApi.fs │ └── OpenApi.fsproj ├── global.json ├── site/ │ ├── Site.fs │ └── Site.fsproj ├── src/ │ └── Falco/ │ ├── Core.fs │ ├── Falco.fsproj │ ├── Multipart.fs │ ├── Request.fs │ ├── RequestData.fs │ ├── RequestValue.fs │ ├── Response.fs │ ├── Routing.fs │ ├── Security.fs │ ├── String.fs │ └── WebApplication.fs └── test/ ├── Falco.IntegrationTests/ │ ├── Falco.IntegrationTests.fsproj │ └── Program.fs ├── Falco.IntegrationTests.App/ │ ├── Falco.IntegrationTests.App.fsproj │ └── Program.fs └── Falco.Tests/ ├── Common.fs ├── Falco.Tests.fsproj ├── MultipartTests.fs ├── Program.fs ├── RequestDataTests.fs ├── RequestTests.fs ├── RequestValueTests.fs ├── ResponseTests.fs ├── RoutingTests.fs ├── SecurityTests.fs ├── StringTests.fs └── WebApplicationTests.fs ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root=true [*] charset=utf-8 end_of_line=lf trim_trailing_whitespace=true insert_final_newline=true indent_style=space indent_size=4 max_line_length=120 tab_width=4 ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: push: branches: [master] paths-ignore: - '**/*.md' - 'docs/**' pull_request: jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup .NET Core SDK uses: actions/setup-dotnet@v4 with: dotnet-version: | 8.0.x 9.0.x 10.0.x - name: Restore dependencies run: dotnet restore - name: Build run: dotnet build -c Release --no-restore - name: Unit Tests run: dotnet test test/Falco.Tests -c Release --no-build - name: Integration Tests run: dotnet test test/Falco.IntegrationTests -c Release --no-build ================================================ FILE: .gitignore ================================================ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore # User-specific files *.rsuser *.suo *.user *.userosscache *.sln.docstates [Ee]xamples/[Ss]andbox/ [Ss]andbox/ *.sqlite [Mm]emory # User-specific files (MonoDevelop/Xamarin Studio) *.userprefs # Mono auto generated files mono_crash.* # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ [Aa][Rr][Mm]/ [Aa][Rr][Mm]64/ bld/ [Bb]in/ [Oo]bj/ [Ll]og/ [Ll]ogs/ # Visual Studio 2015/2017 cache/options directory .vs/ # Uncomment if you have tasks that create the project's static files in wwwroot #wwwroot/ # Visual Studio 2017 auto generated files Generated\ Files/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* # NUnit *.VisualState.xml TestResult.xml nunit-*.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c # Benchmark Results BenchmarkDotNet.Artifacts/ # .NET Core project.lock.json project.fragment.lock.json artifacts/ # StyleCop StyleCopReport.xml # Files built by Visual Studio *_i.c *_p.c *_h.h *.ilk *.meta *.obj *.iobj *.pch *.pdb *.ipdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *_wpftmp.csproj *.log *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opendb *.opensdf *.sdf *.cachefile *.VC.db *.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx *.sap # Visual Studio Trace Files *.e2e # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # AxoCover is a Code Coverage Tool .axoCover/* !.axoCover/settings.json # Visual Studio code coverage results *.coverage *.coveragexml # NCrunch _NCrunch_* .*crunch*.local.xml nCrunchTemp_* # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # Note: Comment the next line if you want to checkin your web deploy settings, # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # Microsoft Azure Web App publish settings. Comment the next line if you want to # checkin your Azure Web App publish settings, but sensitive information contained # in these scripts will be unencrypted PublishScripts/ # NuGet Packages *.nupkg # NuGet Symbol Packages *.snupkg # The packages folder can be ignored because of Package Restore **/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/[Pp]ackages/repositories.config # NuGet v3's project.json files produces more ignorable files *.nuget.props *.nuget.targets # Microsoft Azure Build Output csx/ *.build.csdef # Microsoft Azure Emulator ecf/ rcf/ # Windows Store app package directories and files AppPackages/ BundleArtifacts/ Package.StoreAssociation.xml _pkginfo.txt *.appx *.appxbundle *.appxupload # Visual Studio cache files # files ending in .cache can be ignored *.[Cc]ache # but keep track of directories ending in .cache !?*.[Cc]ache/ # Others ClientBin/ ~$* *~ *.dbmdl *.dbproj.schemaview *.jfm *.pfx *.publishsettings orleans.codegen.cs # Including strong name files can present a security risk # (https://github.com/github/gitignore/pull/2483#issue-259490424) #*.snk # Since there are multiple workflows, uncomment next line to ignore bower_components # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) #bower_components/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm ServiceFabricBackup/ *.rptproj.bak # SQL Server files *.mdf *.ldf *.ndf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings *.rptproj.rsuser *- [Bb]ackup.rdl *- [Bb]ackup ([0-9]).rdl *- [Bb]ackup ([0-9][0-9]).rdl # Microsoft Fakes FakesAssemblies/ # GhostDoc plugin setting file *.GhostDoc.xml # Node.js Tools for Visual Studio .ntvs_analysis.dat node_modules/ # Visual Studio 6 build log *.plg # Visual Studio 6 workspace options file *.opt # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) *.vbw # Visual Studio LightSwitch build output **/*.HTMLClient/GeneratedArtifacts **/*.DesktopClient/GeneratedArtifacts **/*.DesktopClient/ModelManifest.xml **/*.Server/GeneratedArtifacts **/*.Server/ModelManifest.xml _Pvt_Extensions # Paket dependency manager .paket/paket.exe paket-files/ # FAKE - F# Make .fake/ # CodeRush personal settings .cr/personal # Python Tools for Visual Studio (PTVS) __pycache__/ *.pyc # Cake - Uncomment if you are using it # tools/** # !tools/packages.config # Tabs Studio *.tss # Telerik's JustMock configuration file *.jmconfig # BizTalk build output *.btp.cs *.btm.cs *.odx.cs *.xsd.cs # OpenCover UI analysis results OpenCover/ # Azure Stream Analytics local run output ASALocalRun/ # MSBuild Binary and Structured Log *.binlog # NVidia Nsight GPU debugger configuration file *.nvuser # MFractors (Xamarin productivity tool) working folder .mfractor/ # Local History for Visual Studio .localhistory/ # BeatPulse healthcheck temp database healthchecksdb # Backup folder for Package Reference Convert tool in Visual Studio 2017 MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ .DS_Store # Rider (JetBrain's cross-platform .NET IDE) working folder .idea/ .vscode/launch.json .vscode/launch.json ================================================ FILE: Build.ps1 ================================================ [CmdletBinding()] param ( [Parameter(HelpMessage="The action to execute.")] [ValidateSet("Build", "Test", "IntegrationTest", "Pack", "BuildSite", "DevelopSite")] [string] $Action = "Build", [Parameter(HelpMessage="The msbuild configuration to use.")] [ValidateSet("Debug", "Release")] [string] $Configuration = "Debug", [switch] $NoRestore, [switch] $Clean ) function RunCommand { param ([string] $CommandExpr) Write-Verbose " $CommandExpr" Invoke-Expression $CommandExpr } $rootDir = $PSScriptRoot $srcDir = Join-Path $rootDir 'src' $testDir = Join-Path $rootDir 'test' $docsOutputDir = Join-Path $rootDir 'docs' switch ($Action) { "Test" { $projectdir = Join-Path $testDir 'Falco.Tests' } "IntegrationTest" { $projectdir = Join-Path $testDir 'Falco.IntegrationTests' } "Pack" { $projectDir = Join-Path $srcDir 'Falco' } "BuildSite" { $projectDir = Join-Path $rootDir 'site' } "DevelopSite" { $projectDir = Join-Path $rootDir 'site' } Default { $projectDir = Join-Path $srcDir 'Falco' } } if(!$NoRestore.IsPresent) { RunCommand "dotnet restore $projectDir --force --force-evaluate --nologo --verbosity quiet" } if ($Clean) { RunCommand "dotnet clean $projectDir -c $Configuration --nologo --verbosity quiet" } switch ($Action) { "Test" { RunCommand "dotnet test `"$projectDir`"" } "IntegrationTest" { RunCommand "dotnet test `"$projectDir`"" } "Pack" { RunCommand "dotnet pack `"$projectDir`" -c $Configuration --include-symbols --include-source" } "BuildSite" { RunCommand "dotnet run --project `"$projectDir`" -- `"$docsOutputDir`"" } "DevelopSite" { RunCommand "dotnet watch --project `"$projectDir`" -- run `"$docsOutputDir`"" } Default { RunCommand "dotnet build `"$projectDir`" -c $Configuration" } } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. ## [6.0.0] - _unreleased_ ### Added - `Request.getBodyStringOptions` to allow configuration of the maximum request body size, with a default of `Multipart.DefaultMaxSize` (32MB). - `Request.getFormOptions` to allow configuration of the maximum form size, with a default of `Multipart.DefaultMaxSize` (32MB). - Guards to `RequestValue.parse` to prevent oversized numerics or leading-zero numeric strings (non-decimal) from being parsed as floats. - Max size constraint to multipart form streaming, with a default of `Multipart.DefaultMaxSize` (32MB). - `FormData.IsValid` property to indicate whether CSRF validation succeeded for form requests. ### Changed - `Request.getForm` now automatically performs CSRF validation if antiforgery services are registered and the request method is POST, PUT, PATCH, or DELETE. Returns `FormData` with `IsValid = false` when validation fails. - Increased multipart form streaming buffer size from 1024 to 8192 bytes to improve performance of large file uploads. - `Request.ifAuthenticatedInRole` accepts roles as `seq` instead of `list` for more flexible input options. - `Request.getJsonOptions` explicitly checks for application/json content type and empty body, returning default form of `T`. Returns 415 Unsupported Media Type response when content type is missing. - `Response.signInOptions` and `Response.signOutOptions` now conditionally set status code 301 and Location header only when `AuthenticationProperties.RedirectUri` is present. - `Response.challengeOptions` and `Response.challengeAndRedirect` now set status code 401 and WWW-Authenticate header to properly indicate authentication challenge. ### Fixed - Unnecessary URL decode in `RequestValue.parseString`. - Default `JsonSerializerOptions` used for JSON deserialization, optimized by making read-only and static to prevent unnecessary allocations. - `Response.ofAttachment` now properly escapes provided filename to ensure correct handling of special characters and spaces across different browsers. - Added missing Location header to `Response.signInOptions` and `Response.signOutOptions` when redirect URI is specified. ### Removed - `Request.getFormSecure` and `Request.mapFormSecure`, which were explicit enablements of the now default CSRF validation for form requests. `Request.getForm` and `Request.mapForm` now include automatic CSRF validation when antiforgery services are registered and request method is POST, PUT, PATCH, or DELETE. ## [5.2.0] - 2025-12-21 ### Added - `net10.0` support. ## [5.1.0] - 2025-09-09 ### Added - `Response.ofFragment` and `Response.ofFragmentCsrf` to return HTML fragments by element ID, with optional CSRF token support. - `Falco.Markup` [version 1.4.0](https://www.nuget.org/packages/Falco.Markup/1.4.0), which reverted API to 1.2.0 and provided correct support for template fragments. ## [5.0.3] - 2025-09-09 ### Added - `Falco.Markup` [version 1.3.0](https://www.nuget.org/packages/Falco.Markup/1.3.0), which enables support for template fragment responses. ## [5.0.2] - 2025-06-26 ### Fixed - Request module error for JSON transfer encoding chunked requests. ## [5.0.1] - 2025-06-26 ### Added - `Falco.Markup` [version 1.2.0](https://www.nuget.org/packages/Falco.Markup/1.2.0) support, which includes a unified DSL module introduces consistent prefix-based naming conventions for elements, text shortcuts, and attributes, making code cleaner and more readable. ### Fixed - `Request.mapCookies` and `Request.mapHeaders` re-added, which were accidentally removed in the 5.0.0 release. ## [5.0.0] - 2025-01-28 ### Added - Declarative OpenAPI support. - `RequestData` (and `RequestValue`) to support complex form & query submissions, - Provided by an HTTP key/value pair (i.e., `name=falco&classification=toolkit`) parser. - A derivative `FormData` contains parsed `RequestValue` and access to `IFormFileCollection`. - `HttpContext.Plug` for generic injection support of dependencies within `HttpHandler`'s (service locator pattern). - `Request.getJson` for generic JSON request deserialization, using default settings (property name case-insensitive, trailing commas allowed). - `Request.getCookies`, replacing `Request.getCookie`. - `Response.signInOptions` to sign in claim principal for provided scheme and options then responds with a 301 redirect to provided URL. - `Response.challengeAndRedirect`, replacing `Response.challengeWithRedirect`. - `Routing.map[Get|Head|Post|Put|Patch|Delete|Options|Trace|Any]` which produces `HttpEndpoint` by associating a route pattern to an `HttpHandler` after mapping route. - `Routing.setDisplayName` to set the display name of the endpoint. - `Routing.setOrder` to set the order number of the endpoint. - `WebApplication.run`, registers the provided `HttpHandler` as the terminal middleware and runs the application. ### Changed - `Xss` module renamed to `Xsrf`. Functions: `Xsrf.antiforgeryInput`, `Xsrf.getToken` & `Xsrf.validateToken`. ### Fixed - Missing cancellation token pass-through during form reading, `multipart/form-data` streaming and JSON serialization/deserialization. - Empty request body support for JSON request methods. - `WebApplication.UseFalcoNotFound` & `IApplicationBuilder.UseFalcoNotFound` to correctly terminate by returning `unit` akin to the native method. ### Removed - `net6.0` support dropped (end of life 2024-11-12). - `webHost [||] {}` builder removed. - `config {}` builder removed. - `HttpContext.GetLogger()` extension removed. - `IApplicationBuilder.IsDevelopment()`, `IApplicationBuilder.UseWhen()` extensions removed. - `Services.inject` (and overloads) removed. - `Response.withContentLength` removed (unsupported). - `StringCollectionReader` and derivatives removed (`FormCollectionReader`, `QueryCollectionReader`, `RouteCollectionReader`, `HeaderCollectionReader`, and `CookieCollectionReader`). - All replaced by homogenous `RequestData` type. - `Request.streamForm`, `Request.streamFormSecure`, `Request.mapFormStream` and `Request.mapFormStreamSecure` removed. - `Falco.Security.Crypto` and `Falco.Security.Auth` modules removed. - Removed `Request.getCookie`, renamed `Request.getCookies`. - Removed `Response.challengeWithRedirect`, renamed `Response.challengeAndRedirect`. - Removed `Response.debugRequest`. ## [4.0.6] - 2023-12-12 - `net7.0` and `net8.0` support added. - Added ability to configure `IWebHostBuilder` to host builder (i.e., `webHost [||] { web_host (fun webHost -> ...) }`). ## [4.0.5] - 2023-11-16 - Execution order of configuration builder (i.e., `configuration { add_env }`) set to match [default](https://learn.microsoft.com/en-us/dotnet/core/extensions/configuration#alternative-hosting-approach) configuration behaviour. ## [4.0.4] - 2023-03-13 ### Added - `Request.getFormSecure` and `Request.streamFormSecure`. - `use_cors` to host builder (i.e., `webHost [||] { use_cors }`). ### Removed - Unused types `HttpContextAccessor` and `AsyncHttpContextAccessor`. ## [4.0.3] - 2023-01-01 ### Added - Working tutorial sample. - Documentation website generator `/site`, and output `/docs`. ### Removed - Internal utility functions `httpPipe` and `httpPipeTask`. See issue #94, #95. ## [4.0.2] - 2022-11-30 ### Fixed - NuGet package metadata, invalid readme. ### Changed - Hello world sample to use ASP.NET static file middleware. - Spelling and grammar of comments. See #96. ### Removed - Unused internal function `String.parseInt`. ## [4.0.1] - 2022-11-23 ### Added - `Response.debugRequest`, which pretty prints the content of the current request to the screen. - Related community projects and libraries to README.md. ### Fixed - NuGet package metadata, invalid icon path. ## [4.0.0] - 2022-11-07 The project no longer intends to support anything prior to net6.0, which enables the built-in `task {}` computation expression. ### Added - `StringCollectionReader.GetChildren`, safely retrieves a collection of readers. Intended to be used with the "dot notation" collection wire format (i.e., Person.First=John&Person.Last=Doe&Person.First=Jane&Person.Last=Doe). - `MultipartReader.StreamSectionsAsync` for async streaming of multipart/form-data, following MSFT [spec](https://learn.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads). - `Services.inject` helpers, for CPS-style dependency injection, supporting up to five generic input types. - `in_memory`, `required_ini`, `optional_ini`, `required_xml`, `optional_xml` custom operations added to the configuration builder. ### Changed - `StringCollectionReader` abstract attribute removed, to support nested readers. - `StringCollectionReader.Get{String|StringNonEmpty|Int16|Int32|Int|Int64|Boolean|Float|Decimal|DateTime|DateTimeOffset|Guid|TimeSpan}` default value made optional. - Upgraded host builder expression from `IWebHostBuilde` to `WebApplication`. ### Removed - `Falco.Markup`, module has been extracted into it's own [project](https://github.com/FalcoFramework/Falco.Markup). - Additional `StringCollectionReader` constructors, per-collection type. - `StringCollectionReader.TryArrayString`, use `StringCollectionReader.GetStringArray` - `StringCollectionReader.TryArrayInt16`, use `StringCollectionReader.GetInt16Array` - `StringCollectionReader.TryArrayInt32`, use `StringCollectionReader.GetInt32Array` - `StringCollectionReader.TryArrayInt`, use `StringCollectionReader.GetIntArray` - `StringCollectionReader.TryArrayInt64`, use `StringCollectionReader.GetInt64Array` - `StringCollectionReader.TryArrayBoolean`, use `StringCollectionReader.GetBooleanArray` - `StringCollectionReader.TryArrayFloat`, use `StringCollectionReader.GetFloatArray` - `StringCollectionReader.TryArrayDecimal`, use `StringCollectionReader.GetDecimalArray` - `StringCollectionReader.TryArrayDateTime`, use `StringCollectionReader.GetDateTimeArray` - `StringCollectionReader.TryArrayDateTimeOffset`, use `StringCollectionReader.GetDateTimeOffsetArray` - `StringCollectionReader.TryArrayGuid`, use `StringCollectionReader.GetGuidArray` - `StringCollectionReader.TryArrayTimeSpan`, use `StringCollectionReader.GetTimeSpanArray` - `HttpRequest.IsMultipart`, `HttpRequest.TryStreamFormAsync`, use `HttpRequest.StreamFormAsync()` - `Request.tryBindRoute`, use `Request.getRoute`. - `Request.tryBindQuery`, use `Request.getQuery`. - `Request.tryBindForm`, use `Request.getForm`. - `Request.tryBindFormStream`, use `Request.tryStreamForm`. - `Request.tryBindCookie`, use `Request.getCookie`. - `Request.getJson`, use `Request.getJsonOptions Constants.defaultJsonOptions`. - `Request.tryBindJsonOptions`, use `Request.getJsonOptions`. - `Request.tryBindJson`, use `Request.getJsonOptions Constants.defaultJsonOptions`. - `Request.bindJson`, use `Request.mapJson`. - `Request.bindRoute`, use `Request.mapRoute`. - `Request.bindQuery`, use `Request.mapQuery`. - `Request.bindCookie`, use `Request.mapCookie`. - `Request.bindForm`, use `Request.mapForm`. - `Request.bindFormStream`, use `Request.mapFormStream`. - `Request.bindFormSecure`, use `Request.mapFormSecure`. - `Request.bindFormStreamSecure`, use `Request.mapFormStreamSecure`. - `Response.withHeader`, use `Response.withHeaders [ x ]`. - `Response.redirect`, use `Response.redirectTemporarily` or `Response.redirectPermanently` ## [3.1.14] - 2022-08-29 ### Added - Embedded readme and project icon into NuGet package. - Additional obsolete attributes to functions in preparation for v4.x. ### Fixed - `Request.mapJson` failing to invoke next handler, caused by a bad merge which left the valid function body commented out. ## [3.1.13] - 2022-08-11 ### Added - Obsolete attributes to `Request.bind{Json|Route|Query|Cookie|Form|FormStream|FormSecure|FormStreamSecure} functions in preparation for v4.x. ### Fixed - Typo in `Markup.Attr.httpEquiv`. ## [3.1.12] - 2022-05-20 ### Added - `Auth.signInOptions` to establish an authenticated context for the provide scheme, options and principal - `Markup.Attr.open'`. - Missing .gitignore items for JetBrains. ## [3.1.11] - 2/8/2022 ### Added - `Auth.challenge` to challenge the specified authentication scheme. - `Response.challengeWithRedirect` to challenge the specified authentication scheme and redirect URI. ### Fixed - Website to address certain accessibility issues. ## [3.1.10] - 12/14/2021 ### Changed - Incorporated built-in `System.Task` expression, with compiler directives to continue supporting Ply usage. ## [3.1.9] - 12/6/2021 ### Changed - `StringCollectionReader` lookups made case-insensitive. ## [3.1.8] - 12/3/2021 ### Added - `net6.0` support. ### Changed - Embedded PDBs to faciliate sourcelink. ## [3.1.7] - 9/24/2021 ### Added - `HttpVerb.toHttpMethodMetadata` to properly capture the `HttpVerb.ANY` to produce an empty `HttpMethodData` (not `HttpMethodData [| "ANY" |]`). ## [3.1.6] - 9/24/2021 ### Removed - Mistakenly added, experimental `Request.signOut` function. ## [3.1.5] - 9/24/2021 ### Added - Route name metadata to support ASP.NET link generator. - Null check to internal `Response.writeString`. - Explicit starting size for the internal `StringBuilder` within `XmlNodeSerializer` ## [3.1.4] - 8/24/2021 ### Added - Comparative view engine benchmarks. - `XmlNodeSerializer` type. - Source link support. ## [3.1.3] - 8/4/2021 ### Added - Dependency on [Ply](https://github.com/crowded/ply). - `Request.authenticate` to authenticate the current request using the provided scheme. ### Removed - TPL helpers. ## [3.1.2] - 7/30/2021 ### Changed - CSRF validation switched to occur _after_ form is streamed, which includes enabling buffering for the request body. ## [3.1.1] - 7/27/2021 ### Added - `stringf` function. ### Removed - Dependency on [Taskbuilder.fs](https://github.com/rspeele/Taskbuilder.fs), replaced with TPL helpers. ## [3.1.0] - 7/27/2021 ### Added - `FalcoEndpointDataSource` to properly integrate with ASP.NET endpoint routing. - Binary response handlers `Response.ofBinary`, `Response.ofAttachment`. - `IConfiguration` builder expression with JSON input hooks. - `Auth.getClaimValue` - `IServiceCollection` operations to the HostBuilder expression: `add_service`, `add_antiforgery`, `add_cookie`, `add_conf_cookies`, `add_authorization`, `add_data_protection`, `add_http_client`. - `IApplicationBuilder` operations to the HostBuilder expression: `use_middleware`, `use_if`, `use_ifnot`, `use_authentication`, `use_authorization`, `use_caching`, `use_compression`, `use_hsts`, `use_https`, `use_static_files`. - `not_found` operation added to HostBuilder expression to serve default document when no endpoints are matched. ### Changed - Internal `Response.writeBytes` to use `BodyWriter`. ### Fixed - Optional JSON config file fix, misassigned. ### Removed - MVC and REST templates ## [3.0.5] - 6/14/2021 ### Added - PowerShell website build script. ### Fixed - Null reference exception when consuming `IFormCollection.Files`. ## [3.0.4] - 5/5/2021 ### Added - `Response.signInAndRedirect`. - `IEndpointRouteBuilder` extension method `UserFalcoEndpoints`. ## [3.0.3] - 4/10/2021 ### Added - `Auth.hasScope`, `Auth.tryFindClaim`, `Auth.getClaim`. - `Request.ifAuthenticatedWithScope`. - `CookieCollectionReader`, accessible get `Request.getCookie`, `Request.tryBindCookie`, `Request.bindCookie`, `Request.mapCookie`. - `StringUtils.strSplit`. ## [3.0.2] - 12/8/2020 ### Added - `Markup.Elem.form`, `Markup.Elem.button`, `Markup.Elem.script` ## [3.0.1] - 12/1/2020 ### Fixed - `Markup.Templates.html5` not using provided language code. ## [3.0.0] - 11/27/2020 ### Added - `net5.0` support. - `IHost` builder expression, `webHost [||] {}`. - `IServiceCollection.AddFalco`. - `IServiceCollection.AddFalco (routeOptions : RouteOptions -> unit)`. - `IApplicationBuilder.UseFalco (endpoints : HttpEndpoint list)`. - `IApplicationBuilder.UseFalcoExceptionHandler (exceptionHandler : HttpHandler)`. - `QueryCollectionReader`. - `HeaderCollectionReader`. - `RouteCollectionReader`. ### Removed - Extensions, `HttpRequest.GetHeader`, `HttpRequest.GetRouteValues`, `HttpRequest.GetRouteReader`. - Exceptions. `ExceptionHandler`, `ExceptionHandlingMiddleware`. - Host module, `Host.defaultExceptionHandler`, `Host.defaultNotFoundHandler`, `Host.startWebHostDefault`, `Host.startWebHost`. - `IApplicationBuilder.UseHttpEndpoints (endpoints : HttpEndpoint list)` replaced by `IApplicationBuilder.UseFalco (endpoints : HttpEndpoint list)`. - `Request.getHeader`, `Request.getRouteValues` replaced by `Request.getRoute`, `Request.tryGetRouteValue`. - `StringCollectionReader` ? dynamic operator ## [2.1.0] - 11/11/2020 ### Added - Multimethod `HttpEndpoint` support. - `StringCollectionReader.TryGetStringNonEmpty` which returns `None` for empty, whitespace and null value strings. ## [2.0.4] - 11/9/2020 ### Added - `Request.tryBindRoute`, `Request.mapRoute` and `Request.bindRoute`. - `Request.bindQuery`. - `Request.bindJson` which uses `System.Text.Json`. ## [2.0.3] - 10/31/2020 ### Added - Dependency on [Taskbuilder.fs](https://github.com/rspeele/Taskbuilder.fs), with internal extesion for `Task -> Task` conversion. ## [2.0.2] - 7/31/2020 ### Added - `Request.validateCsrfToken` which uses `Microsoft.AspNetCore.Antiforgery`. - `Response.ofJson` which uses `System.Text.Json` and references `Constants.defaultJsonOptions`. - `Response.ofEmpty`. ## [2.0.1] - 7/20/2020 ### Changed - Parameter ordering for `Response.withCookieOptions`, `Response.ofJsonOptions` to ensure configuration uniformly occured first. ## [2.0.0] - 7/12/2020 ### Added - `HttpResponseModifier` defined as `HttpContext -> HttpContext` used to make non-IO modifications to the `HttpResponse`. - `Response` and `Request` modules, which provide functional access to the `HttpResponse` and `HttpRequest` respectively. - `Response.redirect` - `Response.withHeader` - `Response.withContentLength` - `Response.withContentType` - `Response.withStatusCode` - `Response.withCookie` - `Response.withCookieOptions` - `Response.ofString` - `Response.ofPlainText` - `Response.ofHtml` - `Response.ofJson` - `Response.ofJsonOptions ` - `Request.getVerb` - `Request.getRouteValues` - `Request.tryGetRouteValue` - `Request.getQuery` - `Request.tryBindQuery` - `Request.getForm` - `Request.tryBindForm` - `Request.tryStreamForm` - `Request.tryBindJson` - `Request.tryBindJsonOptions` ### Changed - `HttpHandler` definition changed to `HttpContext -> Task`. - `Falco.ViewEngine` becomes `Falco.Markup` - Markup functions are now fully qualified (i.e., `Elem.h1` instead of `h1`). - `webApp ## [1.2.3] - 7/2/2020 ## [1.2.2] - 6/29/2020 ## [1.2.1] - 6/28/2020 ## [1.2.0] - 6/23/2020 ## [1.1.0] - 6/6/2020 Still kicking myself over this brainfart. Starting version `1.1` for the win 🙄. ================================================ FILE: CNAME ================================================ www.falcoframework.com ================================================ FILE: Falco.sln ================================================  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco", "src\Falco\Falco.fsproj", "{D6613F0B-7571-432E-964B-B9629B3B06CB}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco.Tests", "test\Falco.Tests\Falco.Tests.fsproj", "{9600E734-196E-4922-929E-8B5906A1FBAA}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco.IntegrationTests.App", "test\Falco.IntegrationTests.App\Falco.IntegrationTests.App.fsproj", "{7A9A92D3-EADC-40D0-8E90-82AE651195F1}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco.IntegrationTests", "test\Falco.IntegrationTests\Falco.IntegrationTests.fsproj", "{5985B9A8-267A-46FF-8E5C-3E016B36E3EA}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D6613F0B-7571-432E-964B-B9629B3B06CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Debug|Any CPU.Build.0 = Debug|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Debug|x64.ActiveCfg = Debug|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Debug|x64.Build.0 = Debug|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Debug|x86.ActiveCfg = Debug|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Debug|x86.Build.0 = Debug|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Release|Any CPU.Build.0 = Release|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Release|x64.ActiveCfg = Release|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Release|x64.Build.0 = Release|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Release|x86.ActiveCfg = Release|Any CPU {D6613F0B-7571-432E-964B-B9629B3B06CB}.Release|x86.Build.0 = Release|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Debug|Any CPU.Build.0 = Debug|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Debug|x64.ActiveCfg = Debug|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Debug|x64.Build.0 = Debug|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Debug|x86.ActiveCfg = Debug|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Debug|x86.Build.0 = Debug|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Release|Any CPU.ActiveCfg = Release|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Release|Any CPU.Build.0 = Release|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Release|x64.ActiveCfg = Release|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Release|x64.Build.0 = Release|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Release|x86.ActiveCfg = Release|Any CPU {9600E734-196E-4922-929E-8B5906A1FBAA}.Release|x86.Build.0 = Release|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Debug|Any CPU.Build.0 = Debug|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Debug|x64.ActiveCfg = Debug|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Debug|x64.Build.0 = Debug|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Debug|x86.ActiveCfg = Debug|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Debug|x86.Build.0 = Debug|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Release|Any CPU.Build.0 = Release|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Release|x64.ActiveCfg = Release|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Release|x64.Build.0 = Release|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Release|x86.ActiveCfg = Release|Any CPU {7A9A92D3-EADC-40D0-8E90-82AE651195F1}.Release|x86.Build.0 = Release|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Debug|Any CPU.Build.0 = Debug|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Debug|x64.ActiveCfg = Debug|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Debug|x64.Build.0 = Debug|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Debug|x86.ActiveCfg = Debug|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Debug|x86.Build.0 = Debug|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Release|Any CPU.ActiveCfg = Release|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Release|Any CPU.Build.0 = Release|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Release|x64.ActiveCfg = Release|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Release|x64.Build.0 = Release|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Release|x86.ActiveCfg = Release|Any CPU {5985B9A8-267A-46FF-8E5C-3E016B36E3EA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {D6613F0B-7571-432E-964B-B9629B3B06CB} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {9600E734-196E-4922-929E-8B5906A1FBAA} = {0C88DD14-F956-CE84-757C-A364CCF449FC} {7A9A92D3-EADC-40D0-8E90-82AE651195F1} = {0C88DD14-F956-CE84-757C-A364CCF449FC} {5985B9A8-267A-46FF-8E5C-3E016B36E3EA} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection EndGlobal ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # Falco [![NuGet Version](https://img.shields.io/nuget/v/Falco.svg)](https://www.nuget.org/packages/Falco) [![build](https://github.com/FalcoFramework/Falco/actions/workflows/build.yml/badge.svg)](https://github.com/FalcoFramework/Falco/actions/workflows/build.yml) ```fsharp open Falco open Microsoft.AspNetCore.Builder let wapp = WebApplication.Create() wapp.Run(Response.ofPlainText "Hello world") ``` [Falco](https://github.com/FalcoFramework/Falco) is a toolkit for building functional-first, full-stack web applications using F#. - Built on the high-performance components of ASP.NET Core. - Seamlessly integrates with existing .NET Core middleware and libraries. - Designed to be simple, lightweight and easy to learn. ## Key Features - Simple and powerful [routing](documentation/routing.md) API. - Uniform API for [accessing _any_ request data](documentation/request.md). - Native F# [view engine](documentation/markup.md). - Asynchronous [request handling](documentation/response.md). - [Authentication](documentation/authentication.md) and [security](documentation/cross-site-request-forgery.md) utilities. - Built-in support for [large uploads](documentation/request.md#multipartform-data-binding) and [binary responses](documentation/response.md#content-disposition). ## Design Goals - Provide a toolset to build full-stack web application in F#. - Should be simple, extensible and integrate with existing .NET libraries. - Can be easily learned. ## Learn The best way to get started is by visiting the [documentation](https://falcoframework.com/docs). For questions and support please use [discussions](https://github.com/FalcoFramework/Falco/discussions). For chronological updates refer to the [changelog](CHANGELOG.md) is the best place to find chronological updates. ### Related Libraries - [Falco.Markup](https://github.com/FalcoFramework/Falco.Markup) - an XML markup module primary used as the syntax for [authoring HTML with Falco](https://www.falcoframework.com/docs/markup.html). - [Falco.Htmx](https://github.com/dpraimeyuu/Falco.Htmx) - a full featured integration with [htmx JS package](https://htmx.org/). - [Falco.OpenApi](https://github.com/FalcoFramework/Falco.OpenApi) - a library for generating OpenAPI documentation from Falco applications. - [Falco.Template](https://github.com/FalcoFramework/Falco.Template) - a .NET SDK [project template](https://learn.microsoft.com/en-us/dotnet/core/tools/custom-templates) to help get started with Falco quickly. - [Falco.UnionRoutes](https://github.com/michaelglass/Falco.UnionRoutes/) - a library for expressing routes as a descriminated union, inspired by Haskell's [Servant](https://docs.servant.dev/). - [CloudSeed](https://cloudseed.xyz/) - a simple, scalable project boilerplate for F# / .NET. ### Community Projects - [Falco GraphQL Sample](https://github.com/adelarsq/falco_graphql_sample) - A sample showing how to use GraphQL on Falco using .NET 6. - [Falco API with Tests Sample](https://github.com/jasiozet/falco-api-with-tests-template) - A sample project using Falco and unit testing. - [Falco + SQLite + Donald](https://github.com/galassie/FalcoSample) - A demo project using Falco, [Donald](https://github.com/pimbrouwers/Donald), and SQLite - [FShopOnWeb](https://github.com/NitroDevs/FShopOnWeb) - An adaptation of the classic [ASP.NET Core sample application](https://github.com/dotnet-architecture/eShopOnWeb) using Falco and an F# architecture. ### Articles - Hamilton Greene - [Spin up a Fullstack F# WebApp in 10 minutes with the CloudSeed Project Template](https://hamy.xyz/blog/2025-01_fsharp-webapp-10-minutes) - Hamilton Greene - [Why I'm Ditching F# + Giraffe For Falco For Building WebApps](https://hamy.xyz/blog/2025-01_ditching-giraffe-for-falco) - Istvan - [Running ASP.Net web application with Falco on AWS Lambda](https://dev.l1x.be/posts/2020/12/18/running-asp.net-web-application-with-falco-on-aws-lambda/) ### Videos - Hamilton Greene - [Build a Fullstack Webapp with F# + Falco](https://www.youtube.com/watch?v=ELPdHdtEIY8) - Hamilton Greene - [Build a Single-File Web API with F# + Falco](https://www.youtube.com/watch?v=SJCHBqrc3sE) - Hamilton Greene - [Why I'm Ditching F# + Giraffe For Falco For Building WebApps](https://www.youtube.com/watch?v=tonPeWfu_WM) - Ben Gobeil - [Why I'm Using Falco Instead Of Saturn | How To Switch Your Backend In SAFE Stack | StonkWatch Ep.13](https://youtu.be/DTy5gIUWvpo) ## Contribute We kindly ask that before submitting a pull request, you first submit an [issue](https://github.com/FalcoFramework/Falco/issues) or open a [discussion](https://github.com/FalcoFramework/Falco/discussions). If functionality is added to the API, or changed, please kindly update the relevant [document](/docs). Unit tests must also be added and/or updated before a pull request can be successfully merged. Only pull requests which pass all build checks and comply with the general coding standard can be approved. If you have any further questions, submit an [issue](https://github.com/FalcoFramework/Falco/issues) or open a [discussion](https://github.com/FalcoFramework/Falco/discussions) or reach out on [Twitter](https://twitter.com/falco_framework). ## Why "Falco"? [Kestrel](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel) has been a game changer for the .NET web stack. In the animal kingdom, "Kestrel" is a name given to several members of the falcon genus. Also known as "Falco". ## Find a bug? There's an [issue](https://github.com/FalcoFramework/Falco/issues) for that. ## License Licensed under [Apache License 2.0](https://github.com/FalcoFramework/Falco/blob/master/LICENSE). ================================================ FILE: docs/CNAME ================================================ www.falcoframework.com ================================================ FILE: docs/docs/authentication.html ================================================ Authentication & Authorization. - Falco Documentation

Authentication & Authorization.

ASP.NET Core has built-in support for authentication and authorization. Falco includes some prebuilt, configurable handlers for common scenarios.

Review the docs for specific implementation details.

Secure Resources

Allow only authenticated access

open Falco

let authScheme = "some.secure.scheme"

let secureResourceHandler : HttpHandler =
    let handleAuth : HttpHandler =
        Response.ofPlainText "hello authenticated user"

    Request.ifAuthenticated authScheme handleAuth

Allow only non-authenticated access

open Falco

let anonResourceOnlyHandler : HttpHandler =
    let handleAnon : HttpHandler =
        Response.ofPlainText "hello anonymous"

    Request.ifNotAuthenticated authScheme handleAnon

Allow only authenticated access when in certain role(s)

open Falco

let secureResourceHandler : HttpHandler =
    let handleAuthInRole : HttpHandler =
        Response.ofPlainText "hello admin"

    let rolesAllowed = [ "Admin" ]

    Request.ifAuthenticatedInRole authScheme rolesAllowed handleAuthInRole

Allow only authenticated acces with a certain scope

open Falco

let secureResourceHandler : HttpHandler =
    let handleAuthHasScope : HttpHandler =
        Response.ofPlainText "user1, user2, user3"

    let issuer = "https://oauth2issuer.com"
    let scope = "read:users"

    Request.ifAuthenticatedWithScope authScheme issuer scope handleAuthHasScope

Terminate authenticated session

open Falco

let logOut : HttpHandler =
    let authScheme = "..."
    let redirectTo = "/login"

    Response.signOutAndRedirect authScheme redirectTo

Next: Host Configuration

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/cross-site-request-forgery.html ================================================ Cross-site Scripting (XSS) Attacks - Falco Documentation

Cross-site Scripting (XSS) Attacks

Cross-site scripting attacks are extremely common since they are quite simple to carry out. Fortunately, protecting against them is as easy as performing them.

The Microsoft.AspNetCore.Antiforgery package provides the required utilities to easily protect yourself against such attacks.

Activating Antiforgery Protection

To use the Falco Xsrf helpers, ensure that the Antiforgery service has been registered.

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection
// ^-- this import enables antiforgery activation

let endpoints =
    [
        // endpoints...
    ]

let bldr = WebApplication.CreateBuilder()

bldr.Services
    .AddAntiforgery()

let wapp = WebApplication.Create()

wapp.UseAntiforgery()
    // ^-- activate Antiforgery before routing
    .UseRouting()
    .UseFalco(endpoints)
    .Run()

Falco XSRF Support

Falco provides a few handlers via Falco.Security.Xsrf:

open Falco.Markup
open Falco.Security

let formView token =
    _html [] [
        _body [] [
            _form [ _methodPost_ ] [
                // using the CSRF HTML helper, recommended to include as first
                // form element
                Xsrf.antiforgeryInput token
                _control "first_name" [] [ _text "First Name" ]
                _control "first_name" [] [ _text "First Name" ]
                _input [ _typeSubmit_ ]
            ]
        ]
    ]

// A handler that demonstrates obtaining a
// CSRF token and applying it to a view
let csrfViewHandler : HttpHandler =
    Response.ofHtmlCsrf formView

// A handler that demonstrates validating
// the request's CSRF token
let mapFormSecureHandler : HttpHandler =
    let mapPerson (form : FormData) =
        { FirstName = form?first_name.AsString()
          LastName = form?last_name.AsString }

    let handleInvalid : HttpHandler =
        Response.withStatusCode 400
        >> Response.ofEmpty

    Request.mapFormSecure mapPerson Response.ofJson handleInvalid

Next: Authentication

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/deployment.html ================================================ Deployment - Falco Documentation

Deployment

One of the key features of Falco is that it contains little to no "magic" (i.e., no hidden reflection or dynamic code). This means that Falco is both trimmable and AOT compatible out of the box.

This means that you can deploy your Falco application as a self-contained executable, or as a native AOT executable, with no additional configuration. A huge benefit of this is that you can deploy your Falco application to any environment, without having to worry about the underlying runtime or dependencies.

Important! If you're in a scale-to-zero hosting environment consider using a ReadyToRun deployment. This will ensure that your application will experience faster cold start times.

Self-contained deployments

It is highly recommended to deploy your Falco application as a self-contained executable. This means that the .NET runtime and all dependencies are included in the deployment package, so you don't have to worry about the target environment having the correct version of .NET installed. This will result in a slightly larger deployment package, but it will ensure that your application runs correctly in any environment. The larger binary size can also be offset by using trim.

Below is an example [Directory.Build.props] that will help enable the non-AOT features. These properties can also be added to you fsproj file.

<Project>
    <PropertyGroup>
        <SelfContained>true</SelfContained>
        <PublishSingleFile>true</PublishSingleFile>
        <PublishTrimmed>true</PublishTrimmed>
        <TrimMode>Link</TrimMode>
        <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
        <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
        <!-- Optional: enable if in scale-to-zero hosting environment -->
        <!-- <PublishReadyToRun>true</PublishReadyToRun> -->
    </PropertyGroup>
</Project>

Native AOT deployments

Publishing your app as Native AOT produces an app that's self-contained and that has been ahead-of-time (AOT) compiled to native code. Native AOT apps have faster startup time and smaller memory footprints. These apps can run on machines that don't have the .NET runtime installed.

Since AOT deployments require trimming, and are single file by nature the only required msbuild property is:

<Project>
    <PropertyGroup>
        <PublishAot>true</PublishAot>
    </PropertyGroup>
</Project>

Next: Example - Hello World

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/example-basic-rest-api.html ================================================ Example - Basic REST API - Falco Documentation

Example - Basic REST API

This example demonstrates how to create a basic REST API using Falco. The API will allow users to perform CRUD (Create, Read, Update, Delete) operations on a simple resource, users in this case.

The API will be built using the following components, in addition to the Falco framework:

  • System.Data.SQLite, which provides SQLite support, built and maintained by the SQLite developers.
  • Donald which simplifies database access, built and maintained by the Falco developers.

For simplicity, we'll stick to sychronous database access in this example. However, you can easily adapt the code to use asynchronous database access if needed. Specific to SQLite, in many cases it is better to use synchronous access, and let SQLite handle serialization for you.

The code for this example can be found here.

Creating the Application Manually

> dotnet new falco -o BasicRestApiApp
> cd BasicRestApiApp
> dotnet add package System.Data.SQLite
> dotnet add package Donald

Overview

The API will consist of four endpoints:

  • GET /users: Retrieve all users.
  • GET /users/{username}: Retrieve a user by username.
  • POST /users: Create a new user.
  • DELETE /users/{username}: Delete a user by username.

Users will be stored in a SQLite database, and the API will use Donald to interact with the database. Our user model will be a simple record type with two properties: Username and Full Name.

type User =
    { Username : string
      FullName : string }

It's also valueable to have a concrete type to represent API errors. This will be used to return error messages in a consistent format.

type Error =
    { Code : string
      Message : string }

Data Access

To interact with the SQLite database, we'll create some abstractions for establishing new connections and performing database operations.

A connection factory is a useful concept to avoid passing around connection strings. It allows us to create new connections without needing to know the details of how they are created.

type IDbConnectionFactory =
    abstract member Create : unit -> IDbConnection

We'll also define an interface for performing list, create, read and delete operations against a set of entities.

type IStore<'TKey, 'TItem> =
    abstract member List : unit   -> 'TItem list
    abstract member Create : 'TItem -> Result<unit, Error>
    abstract member Read : 'TKey -> 'TItem option
    abstract member Delete : 'TKey -> Result<unit, Error>

The IStore interface is generic, allowing us to use it with any type of entity. In our case, we'll create a concrete implementation for the User entity.

Implementing the Store

Error Responses

The API will return error responses in a consistent format. To do this, we'll create three functions for the common error cases: notFound, badRequest, and serverException.

module ErrorResponse =
    let badRequest error : HttpHandler =
        Response.withStatusCode 400
        >> Response.ofJson error

    let notFound : HttpHandler =
        Response.withStatusCode 404 >>
        Response.ofJson { Code = "404"; Message = "Not Found" }

    let serverException : HttpHandler =
        Response.withStatusCode 500 >>
        Response.ofJson { Code = "500"; Message = "Server Error" }

Here you can see our error type in action, which is used to return a JSON response with the error code and message. The signature of the badRequest function is a bit different, as it takes an error object as input and returns a HttpHandler. The reason for this is that we intend to invoke this function from within our handlers, and we want to be able to pass the error object directly to it.

Defining the Endpoints

It can be very useful to define values for the endpoints we want to expose. This allows us to easily change the endpoint paths in one place if needed, and also provides intellisense support when using the endpoints in our code.

module Route =
    let userIndex = "/users"
    let userAdd = "/users"
    let userView = "/users/{username}"
    let userRemove = "/users/{username}"

Next, let's implement the handlers for each of the endpoints. First, we'll implement the GET /users endpoint, which retrieves all users from the database.

module UserEndpoint =
    let index : HttpHandler = fun ctx ->
        let userStore = ctx.Plug<IStore<string, User>>()
        let allUsers = userStore.List()
        Response.ofJson allUsers ctx

The index function retrieves the IStore instance from the dependency container and calls the List method to get all users. The result is then returned as a JSON response.

Next, we'll implement the POST /users endpoint, which creates a new user.

module UserEndpoint =
    // ... index handler ...
    let add : HttpHandler = fun ctx -> task {
        let userStore = ctx.Plug<IStore<string, User>>()
        let! userJson = Request.getJson<User> ctx
        let userAddResponse =
            match userStore.Create(userJson) with
            | Ok result -> Response.ofJson result ctx
            | Error error -> ErrorResponse.badRequest error ctx
        return! userAddResponse }

The add function retrieves the IStore instance from the dependency container and calls the Create method to add a new user. The result is then returned as a JSON response. If the user creation fails, we return a bad request error.

Next, we'll implement the GET /users/{username} endpoint, which retrieves a user by username.

module UserEndpoint =
    // ... index and add handlers ...
    let view : HttpHandler = fun ctx ->
        let userStore = ctx.Plug<IStore<string, User>>()
        let route = Request.getRoute ctx
        let username = route?username.AsString()
        match userStore.Read(username) with
        | Some user -> Response.ofJson user ctx
        | None -> ErrorResponse.notFound ctx

The view function retrieves the IStore instance from the dependency container and calls the Read method to get a user by username. If the user is found, it is returned as a JSON response. If not, we return a not found error.

Finally, we'll implement the DELETE /users/{username} endpoint, which deletes a user by username.

module UserEndpoint =
    // ... index, add and view handlers ...
    let remove : HttpHandler = fun ctx ->
        let userStore = ctx.Plug<IStore<string, User>>()
        let route = Request.getRoute ctx
        let username = route?username.AsString()
        match userStore.Delete(username) with
        | Ok result -> Response.ofJson result ctx
        | Error error -> ErrorResponse.badRequest error ctx

The remove function retrieves the IStore instance from the dependency container and calls the Delete method to remove a user by username. The result is then returned as a JSON response. If the user deletion fails, we return a bad request error.

Configuring the Application

Conventionally, you'll configure your database outside of your application scope. For the purpose of this example, we'll define and initialize the database during startup.

module Program =
    [<EntryPoint>]
    let main args =
        let dbConnectionFactory =
            { new IDbConnectionFactory with
                member _.Create() = new SQLiteConnection("Data Source=BasicRestApi.sqlite3") }

        let initializeDatabase (dbConnection : IDbConnectionFactory) =
            use conn = dbConnection.Create()
            conn
            |> Db.newCommand "CREATE TABLE IF NOT EXISTS user (username, full_name)"
            |> Db.exec

        initializeDatabase dbConnectionFactory

        // ... rest of the application setup

First we implement the IDbConnectionFactory interface, which creates a new SQLite connection. Then we define a initializeDatabase function, which creates the database and the user table if it doesn't exist. We encapsulate the database initialization in a function, so we can quickly dispose of the connection after use.

Next, we need to register our database connection factory and the IStore implementation in the dependency container.

module Program =
    [<EntryPoint>]
    let main args =
        // ... database initialization ...
        let bldr = WebApplication.CreateBuilder(args)

        bldr.Services
            .AddAntiforgery()
            .AddScoped<IDbConnectionFactory>(dbConnectionFactory)
            .AddScoped<IStore<string, User>, UserStore>()
            |> ignore

Finally, we need to configure the application to use the defined endpoints.

module Program =
    [<EntryPoint>]
    let main args =
        // ... database initialization & dependency registration ...
        let wapp = bldr.Build()

        let isDevelopment = wapp.Environment.EnvironmentName = "Development"

        wapp.UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage)
            .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorResponse.serverException)
            .UseRouting()
            .UseFalco(App.endpoints)
            .Run(ErrorResponse.notFound)

        0 // Exit code

The UseFalco method is used to register the endpoints, and the Run method is used to handle requests that don't match any of the defined endpoints.

Wrapping Up

And there you have it! A simple REST API built with Falco, SQLite and Donald. This example demonstrates how to create a basic CRUD API, but you can easily extend it to include more complex functionality, such as authentication, validation, and more.

Next: Example - Open API

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/example-dependency-injection.html ================================================ Example - Dependency Injection - Falco Documentation

Example - Dependency Injection

An important and nuanced subject to discuss is dependency injection. There's a myriad of beliefs and approaches, all of which have their merit. In the case of Falco, you are living in the world of ASP.NET which has built-in support for this. It works very well and you should use it. But make sure you follow through their docs on how it works and integrates with ASP.NET.

Going back to our basic Hello World app, let's add in an external dependency to demonstrate some of the basics of dependency injection in Falco.

The code for this example can be found here.

Creating the Application Manually

> dotnet new falco -o DependencyInjectionApp

Creating Abstraction

The benefit of abstracting functionality is that it removes the coupling between your implementation and the calling code. You instead rely on an accepted definition of what something does.

F# has excellent support for object programming. There might be an urge to avoid this type of approach because "ugh classes are gross". But suck it up buttercup, they are wickedly useful in many cases and a reminder that F# code doesn't have to adhere to some functional purism.

In the case of our application, we're going to define an abstraction for greeting patrons. Then write a simple implementation.

This is a completely contrived example, created purely to demonstrate how to register and consume dependencies.

type IGreeter =
    abstract member Greet : name : string -> string

type FriendlyGreeter() =
    interface IGreeter with
        member _.Greet(name : string) =
            $"Hello {name} 😀"

Simple enough, we describe an IGreeter as having the ability to Greet in the form of receiving a name string and return a string message. Next we define an implementation that fulfills this interface in a friendly way.

Registering the Dependency

To provide runtime access to our greeter, we have to register the dependency in the container. The abstraction from ASP.NET for this is called IServiceCollection. You can register dependencies in a number of ways, but fundamental to all is the concept of service lifetime. It distills down to:

  • Transient = new for every container access
  • Scoped = new for every client request
  • Singleton = created at startup, or first container access

Our greeter is both stateless and cheap to construct. So any of the lifetimes will suffice. But let's register it as a singleton. This time however, we'll create our web server in two stages, to gain access to the dependency container.

let bldr = WebApplication.CreateBuilder() // <-- create a configurable web application builder

bldr.Services
    .AddSingleton<IGreeter, FriendlyGreeter>() // <-- register the greeter as singleton in the container
    |> ignore

let wapp = bldr.Build() // <-- manifest our WebApplication

let endpoints =
    [
        mapGet "/{name?}"
            (fun r -> r?name.AsString("world"))
            (fun name ctx ->
                let greeter = ctx.Plug<IGreeter>() // <-- access our dependency from the container
                let greeting = greeter.Greet(name) // <-- invoke our greeter.Greet(name) method
                Response.ofPlainText greeting ctx)
    ]

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

Following through you can see the web server being created in two phases. The first to establish the context (i.e., logging, server configuration and dependencies). Second, freezing the final state and creating a configurable web application.

Within the handler you can see the interaction with the dependency container using ctx.Plug<IGreeter>(). This code tells the container to return the implementation it has registered for that abstraction. In our case FriendlyGreeter.

Wrapping Up

Now that we're finished introducing dependency injection, let's move on to a real world example by integrating with an external view engine.

Next: Example - External View Engine

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/example-external-view-engine.html ================================================ Example - External View Engine - Falco Documentation

Example - External View Engine

Falco comes packaged with a built-in view engine. But if you'd prefer to write your own templates, or use an external template engine, that is entirely possible as well.

In this example we'll do some basic page rendering by integrating with scriban. An amazing template engine by xoofx.

The code for this example can be found here.

Creating the Application Manually

> dotnet new falco -o ExternalViewEngineApp
> cd ExternalViewEngineApp
> dotnet add package Scriban

Implementing a Template Engine

There are a number of ways we could achieve this functionality. But in sticking with our previous examples, we'll create an interface. To keep things simple we'll use inline string literals for templates and perform rendering synchronously.

open Scriban

type ITemplate =
    abstract member Render : template: string * model: obj -> string

type ScribanTemplate() =
    interface ITemplate with
        member _.Render(template, model) =
            let tmpl = Template.Parse template
            tmpl.Render(model)

We define an interface ITemplate which describes template rendering as a function that receives a template string literal and a model, producing a string literal. Then we implement this interface definition using Scriban.

Rendering Pages

To use our Scriban template engine we'll need to request it from the dependency container, then pass it our template literal and model.

See dependency injection for further explanation.

Since rendering more than one page is the goal, we'll create a shared renderPage function to do the dirty work for us.

open Falco

module Pages =
    let private renderPage pageTitle template viewModel : HttpHandler = fun ctx ->
        let templateService = ctx.Plug<ITemplate>() // <-- obtain our template service from the dependency container
        let pageContent = templateService.Render(template, viewModel) // <-- render our template with the provided view model as string literal
        let htmlTemplate = """
            <!DOCTYPE html>
            <html>
            <head>
                <meta charset="utf-8">
                <meta name="viewport" content="width=device-width, initial-scale=1">
                <title>{{title}}</title>
            </head>
            <body>
                {{content}}
            </body>
            </html>
        """
        // ^ these triple quoted strings auto-escape characters like double quotes for us
        //   very practical for things like HTML

        let html = templateService.Render(htmlTemplate, {| Title = pageTitle; Content = pageContent |})

        Response.ofHtmlString html ctx // <-- return template literal as "text/html; charset=utf-8" response

In this function we obtain the instance of our template engine, and immediately render the user-provided template and model. Next, we define a local template literal to serve as our layout. Assigning two simple inputs, {{title}} and {{content}}. Then we render the layout template using our template engine and an anonymous object literal {| Title = pageTitle; Content = pageContent |}, responding with the result of this as text/html.

To render pages, we simply need to create a localized template literal, and feed it into our renderPage function. Below we define a home and 404 page.

    let homepage : HttpHandler = fun ctx ->
        let query = Request.getQuery ctx // <-- obtain access to strongly-typed representation of the query string
        let viewModel = {| Name = query?name.AsStringNonEmpty("World") |} // <-- access 'name' from query, or default to 'World'
        let template = """
            <h1>Hello {{ name }}!</h1>
        """
        renderPage $"Hello {viewModel.Name}" template viewModel ctx

    let notFound : HttpHandler =
        let template = """
            <h1>Page not found</h1>
        """
        renderPage "Page Not Found" template {||}

Registering the Template Engine

Since our Scriban template engine is stateless and dependency-free, we can use the generic extension method to register it as a singleton.

Note: Transient and Scoped lifetimes would also work here.

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.DependencyInjection

[<EntryPoint>]
let main args =
    let bldr = WebApplication.CreateBuilder(args)

    bldr.Services
        .AddSingleton<ITemplate, ScribanTemplate>() // <-- register ITemplates implementation as a dependency
        |> ignore

    let endpoints =
        [ get "/" Pages.homepage ]

    let wapp = bldr.Build()

    wapp.UseRouting()
        .UseFalco(endpoints)
        .UseFalcoNotFound(Pages.notFound)
        .Run()

    0 // Exit code

Wrapping Up

This example demonstrates how to effectively integrate an external view engine into your Falco application. By defining a simple interface, implementing it with Scriban and adding it to the dependency container, we can render HTML pages dynamically based on user input.

Next: Example - Basic REST API

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/example-hello-world-mvc.html ================================================ Example - Hello World MVC - Falco Documentation

Example - Hello World MVC

Let's take our basic Hello World to the next level. This means we're going to dial up the complexity a little bit. But we'll do this using the well recognized MVC pattern. We'll contain the app to a single file to make "landscaping" the pattern more straight-forward.

The code for this example can be found here.

Creating the Application Manually

> dotnet new falco -o HelloWorldMvcApp

Model

Since this app has no persistence, the model is somewhat boring. But included here to demonstrate the concept.

We define two simple record types. One to contain the patron name, the other to contain a string message.

module Model =
    type NameGreeting =
        { Name : string }

    type Greeting =
        { Message : string }

Routing

As the project scales, it is generally helpful to have static references to your URLs and/or URL generating functions for dynamic resources.

Routing begins with a route template, so it's only natural to define those first.

module Route =
    let index = "/"
    let greetPlainText = "/greet/text/{name}"
    let greetJson = "/greet/json/{name}"
    let greetHtml = "/greet/html/{name}"

Here you can see we define one static route, and 3 dynamic route templates. We can provide URL generation from these dynamic route templates quite easily with some simple functions.

module Url =
    let greetPlainText name = Route.greetPlainText.Replace("{name}", name)
    let greetJson name = Route.greetJson.Replace("{name}", name)
    let greetHtml name = Route.greetHtml.Replace("{name}", name)

These 3 functions take a string input called name and plug it into the {name} placeholder in the route template. This gives us a nice little typed API for creating our application URLs.

View

Falco comes packaged with a lovely little HTML DSL. It can produce any form of angle-markup, and does so very efficiently. The main benefit is that our views are pure F#, compile-time checked and live alongside the rest of our code.

First we define a shared HTML5 layout function, that references our project style.css. Next, we define a module to contain the views for our greetings.

You'll notice the style.css file resides in a folder called wwwroot. This is an ASP.NET convention which we'll enable later when we build the web server.

module View =
    open Model

    let layout content =
        Templates.html5 "en"
            [ _link [ _href_ "/style.css"; _rel_ "stylesheet" ] ]
            content

    module GreetingView =
        /// HTML view for /greet/html
        let detail greeting =
            layout [
                _h1' $"Hello {greeting.Name} from /html"
                _hr []
                _p' "Greet other ways:"
                _nav [] [
                    _a
                        [ _href_ (Url.greetPlainText greeting.Name) ]
                        [ _text "Greet in text"]
                    _text " | "
                    _a
                        [ _href_ (Url.greetJson greeting.Name) ]
                        [ _text "Greet in JSON " ]
                ]
            ]

The markup code is fairly self-explanatory. But essentially:

  • Elem produces HTML elements.
  • Attr produces HTML element attributes.
  • Text produces HTML text nodes.

Each of these modules matches (or tries to) the full HTML spec. You'll also notice two of our URL generators at work.

Errors

We'll define a couple static error pages to help prettify our error output.

module Controller =
    open Model
    open View

    module ErrorController =
        let notFound : HttpHandler =
            Response.withStatusCode 404 >>
            Response.ofHtml (View.layout [ _h1' "Not Found" ])

        let serverException : HttpHandler =
            Response.withStatusCode 500 >>
            Response.ofHtml (View.layout [ _h1' "Server Error" ])

Here we see the HttpResponseModifier at play, which set the status code before buffering out the HTML response. We'll reference these pages later when be build the web server.

Controller

Our controller will be responsible for four actions, as defined in our route module. We define four handlers, one parameterless greeting and three others which output the user provided "name" in different ways: plain text, JSON and HTML.

module Controller =
    open Model
    open View

    module ErrorController =
        // ...

    module GreetingController =
        let index =
            Response.ofPlainText "Hello world"

        let plainTextDetail name =
            Response.ofPlainText $"Hello {name}"

        let jsonDetail name =
            let message = { Message = $"Hello {name} from /json" }
            Response.ofJson message

        let htmlDetail name =
            { Name = name }
            |> GreetingView.detail
            |> Response.ofHtml

        let endpoints =
            let mapRoute (r : RequestData) =
                r?name.AsString()

            [ get Route.index index
              mapGet Route.greetPlainText mapRoute plainTextDetail
              mapGet Route.greetJson mapRoute jsonDetail
              mapGet Route.greetHtml mapRoute htmlDetail ]

You'll notice that the controller defines its own endpoints. This associates a route to a handler when passed into Falco (we'll do this later). Defining this within the controller is personal preference. But considering controller actions usually operate against a common URL pattern, it allows a private, reusable route mapping to exist (see mapRoute).

Web Server

This is a great opportunity to demonstrate further how to configure a more complex web server than we saw in the basic hello world example.

To do that, we'll define an explicit entry point function which gives us access to the command line argument. By then forwarding these into the web application, we gain further configurability. You'll notice the application contains a file called appsettings.json, this is another ASP.NET convention that provides fully-featured and extensible configuration functionality.

Next we define an explicit collection of endpoints, which gets passed into the .UseFalco(endpoints) extension method.

In this example, we examine the environment name to create an "is development" toggle. We use this to determine the extensiveness of our error output. You'll notice we use our exception page from above when an exception occurs when not in development mode. Otherwise, we show a developer-friendly error page. Next we activate static file support, via the default web root of wwwroot.

We end off by registering a terminal handler, which functions as our "not found" response.

module Program =
    open Controller

    let endpoints =
        [ get Route.index GreetingController.index
          get Route.greetPlainText GreetingController.plainTextDetail
          get Route.greetJson GreetingController.jsonDetail
          get Route.greetHtml GreetingController.htmlDetail ]


    /// By defining an explicit entry point, we gain access to the command line
    /// arguments which when passed into Falco are used as the creation arguments
    /// for the internal WebApplicationBuilder.
    [<EntryPoint>]
    let main args =
        let wapp = WebApplication.Create(args)

        let isDevelopment = wapp.Environment.EnvironmentName = "Development"

        wapp.UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage)
            .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorPage.serverException)
            .Use(StaticFileExtensions.UseStaticFiles)
            .UseFalco(endpoints)
            .UseFalcoNotFound(ErrorPage.notFound)
            .Run()

        0

Wrapping Up

This example was a leap ahead from our basic hello world. But having followed this, you know understand many of the patterns you'll need to know to build end-to-end server applications with Falco. Unsurprisingly, the entire program fits inside 118 LOC. One of the magnificent benefits of writing code in F#.

Next: Example - Dependency Injection

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/example-hello-world.html ================================================ Example - Hello World - Falco Documentation

Example - Hello World

The goal of this program is to demonstrate the absolute bare bones hello world application, so that we can focus on the key elements when initiating a new web application.

The code for this example can be found here.

Creating the Application Manually

> dotnet new falco -o HelloWorldApp

Code Overview

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder
// ^-- this import adds many useful extensions

let wapp = WebApplication.Create()

wapp.UseRouting()
    .UseFalco([
    // ^-- activate Falco endpoint source
        get "/" (Response.ofPlainText "Hello World!")
        // ^-- associate GET / to plain text HttpHandler
    ])
    .Run(Response.ofPlainText "Not found")
    // ^-- activate Falco endpoint source

First, we open the required namespaces. Falco bring into scope the ability to activate the library and some other extension methods to make the fluent API more user-friendly.

Microsoft.AspNetCore.Builder enables us to create web applications in a number of ways, we're using WebApplication.Create() above. It also adds many other useful extension methods, that you'll see later.

After creating the web application, we:

  • Activate Falco using wapp.UseFalco(). This enables us to create endpoints.
  • Register GET / endpoint to a handler which responds with "hello world".
  • Run the app.

Next: Example - Hello World MVC

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/example-htmx.html ================================================ Example - HTMX - Falco Documentation

Example - HTMX

Falco.Htmx brings type-safe htmx support to Falco. It provides a complete mapping of all attributes, typed request data and ready-made response modifiers.

In this example, we'll demonstrate some of the more common htmx attributes and how to use them with Falco.

At this point, we'll assume you have reviewed the docs, other examples and understand the basics of Falco. We don't be covering any of the basics in the code review.

The code for this example can be found here.

Creating the Application Manually

> dotnet new falco -o HtmxApp
> cd HtmxApp
> dotnet add package Falco.Htmx

Layout

First we'll define a simple layout and enable htmx by including the script. Notice the strongly typed reference, HtmxScript.cdnSrc, which is provided by Falco.Htmx and resolves to the official CDN URL.

module View =
    let template content =
        _html [ _lang_ "en" ] [
            _head [] [
                _script [ _src_ HtmxScript.cdnSrc ] [] ]
            _body [] content ]

With our layout defined, we can create a view to represent our starting state.

module View =
    // Layout ...

    let clickAndSwap =
        template [
            _h1' "Example: Click & Swap"
            _div [ _id_ "content" ] [
                _button [
                    _id_ "clicker"
                    Hx.get "/click"
                    Hx.swapOuterHtml ]
                    [ _text "Click Me" ] ] ]

This view contains a button that, when clicked, will send a GET request to the /click endpoint. The response from that request will replace the button with the response from the server.

Components

A nice convention when working with Falco.Markup is to create a Components module within your View module. We'll define one component here.

All of the htmx attributes and properties are mapped within the Hx module. Wherever a limited scope of options exist, strongly typed references are provided. For example, Hx.swapInnerHtml is a strongly typed reference to the hx-swap attribute with the value innerHTML. This is a great way to avoid typos and ensure that your code is type-safe.

module View =
    // Layout & view ...

    module Components =
        let resetter =
            _div [ _id_ "resetter" ] [
                _h2' "Way to go! You clicked it!"
                _br []
                _button [
                    Hx.get "/reset"
                    Hx.swapOuterHtml
                    Hx.targetCss "#resetter" ]
                    [ _text "Reset" ] ]

The resetter component is a simple button that will send a GET request to the server when clicked. The response will replace the entire div with the ID of resetter with the response from the server.

Handlers

Next we define a couple basic handlers to handle the requests for the original document and ajax requests.

module App =
    let handleIndex : HttpHandler =
        Response.ofHtml View.clickAndSwap

    let handleClick : HttpHandler =
        Response.ofHtml View.Components.resetter

    let handleReset : HttpHandler =
        Response.ofFragment "clicker" View.clickAndSwap

The handleIndex handler is returning our full click-and-swap view, containing the clicker button. Clicking it triggers a request to the handleClick handler, which returns the resetter component. Clicking the reset button triggers a request to the handleReset handler, which returns the original clicker button as a [template fragment], extracted from the same view as the original state.

Web Server

To finish things off, we'll map our handlers to the expected routes and initialize the web server.

let wapp = WebApplication.Create()

let endpoints =
    [
        get "/" App.handleIndex
        get "/click" App.handleClick
        get "/reset" App.handleReset
    ]

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

Wrapping Up

That's it! You now have a simple web application that uses htmx to swap out components on the page without a full page reload. This is just the beginning of what you can do with htmx and Falco. You can use the same principles to create more complex interactions and components.

For more information about the htmx integration, check out the Falco.Htmx repository. It contains a full list of all the attributes and properties that are available, as well as examples of how to use them.

Go back to docs home

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/example-open-api.html ================================================ Example - Open API - Falco Documentation

Example - Open API

Open API is a specification for defining APIs in a machine-readable format. It allows developers to describe the structure of their APIs, including endpoints, request/response formats, and authentication methods.

Falco.OpenAPI is a library for generating OpenAPI documentation for Falco applications. It provides a set of combinators for annotating Falco routes with OpenAPI metadata, which can be used to generate OpenAPI documentation.

We'll dial back the complexity a bit from the Basic REST API example and create a simple "fortune teller" Falco application that serves OpenAPI documentation.

The code for this example can be found here.

Creating the Application Manually

> dotnet new falco -o OpenApiApi
> cd OpenApiApp
> dotnet add package Falco.OpenApi

Fortunes

Our fortune teller will return fortune for the name of the person specified. To model this, we'll create two simple record types.

type FortuneInput =
    { Name : string }

type Fortune =
    { Description : string }

For simplicity, we'll use a static member to return a fortune. In a real application, you would likely retrieve this from a database or an external service.

module Fortune =
    let create age input =
        match age with
        | Some age when age > 0 ->
            { Description = $"{input.Name}, you will experience great success when you are {age + 3}." }
        | _ ->
            { Description = $"{input.Name}, your future is unclear." }

OpenAPI Annotations

Next, we'll annotate our route with OpenAPI metadata. This is done using the OpenApi module from the Falco.OpenAPI package. Below is the startup code for our fortune teller application. We'll dissect it after the code block, and then add the OpenAPI annotations.

[<EntryPoint>]
let main args =
    let bldr = WebApplication.CreateBuilder(args)

    bldr.Services
        .AddFalcoOpenApi()
        // ^-- add OpenAPI services
        .AddSwaggerGen()
        // ^-- add Swagger services
        |> ignore

    let wapp = bldr.Build()

    wapp.UseHttpsRedirection()
        .UseSwagger()
        .UseSwaggerUI()
    |> ignore

    let endpoints =
        [
            mapPost "/fortune"
                (fun r -> r?age.AsIntOption())
                (fun ageOpt ->
                    Request.mapJson<FortuneInput> (Fortune.create ageOpt >> Response.ofJson))
                // we'll add OpenAPI annotations here
        ]

    wapp.UseRouting()
        .UseFalco(endpoints)
        .Run()

    0

We've created a simple Falco application that listens for POST requests to the /fortune endpoint. The request body is expected to be a JSON object with a name property. The response will be a JSON object with a description property.

Now, let's add the OpenAPI annotations to our route.

[<EntryPoint>]
let main args =
    // ... application setup code ...
    let endpoints =
        [
            mapPost "/fortune"
                (fun r -> r?age.AsIntOption())
                (fun ageOpt ->
                    Request.mapJson<FortuneInput> (Fortune.create ageOpt >> Response.ofJson))
                |> OpenApi.name "Fortune"
                |> OpenApi.summary "A mystic fortune teller"
                |> OpenApi.description "Get a glimpse into your future, if you dare."
                |> OpenApi.query [
                    { Type = typeof<int>; Name = "Age"; Required = false } ]
                |> OpenApi.acceptsType typeof<FortuneInput>
                |> OpenApi.returnType typeof<Fortune>
        ]

    // ... application startup code ...

    0 // Exit code

In the code above, we use the OpenApi module to annotate our route with metadata.

Here's a breakdown of the annotations:

  • OpenApi.name: Sets the name of the operation.
  • OpenApi.summary: Provides a short summary of the operation.
  • OpenApi.description: Provides a detailed description of the operation.
  • OpenApi.query: Specifies the query parameters for the operation. In this case, we have an optional age parameter.
  • OpenApi.acceptsType: Specifies the expected request body type. In this case, we expect a JSON object that can be deserialized into a FortuneInput record.
  • OpenApi.returnType: Specifies the response type. In this case, we return a JSON object that can be serialized into a Fortune record.

Wrapping Up

That's it! You've successfully created a simple Falco application with OpenAPI documentation. You can now use the generated OpenAPI specification to generate client code, create API documentation, or integrate with other tools that support OpenAPI.

Next: Example - htmx

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/get-started.html ================================================ Getting Started - Falco Documentation

Getting Started

Using dotnet new

The easiest way to get started with Falco is by installing the Falco.Template package, which adds a new template to your dotnet new command line tool:

> dotnet new install "Falco.Template::*"

Afterwards you can create a new Falco application by running:

> dotnet new falco -o HelloWorldApp
> cd HelloWorldApp
> dotnet run

Manually installing

Create a new F# web project:

> dotnet new web -lang F# -o HelloWorldApp
> cd HelloWorldApp

Install the nuget package:

> dotnet add package Falco

Remove any *.fs files created automatically, create a new file named Program.fs and set the contents to the following:

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder
// ^-- this import adds many useful extensions

let endpoints =
    [
        get "/" (Response.ofPlainText "Hello World!")
        // ^-- associate GET / to plain text HttpHandler
    ]

let wapp = WebApplication.Create()

wapp.UseRouting()
    .UseFalco(endpoints)
    // ^-- activate Falco endpoint source
    .Run(Response.ofPlainText "Not found")
    // ^-- run app and register terminal (i.e., not found) middleware

Run the application:

> dotnet run

And there you have it, an industrial-strength Hello World web app. Pretty sweet!

Next: Routing

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/host-configuration.html ================================================ Host Configuration - Falco Documentation

Host Configuration

As your app becomes more complex, you'll inevitably need to reach for some additional host configuration. This is where the Microsoft.AspNetCore.Builder import comes in. This assembly contains many useful extensions for configuring the server (ex: static files, authentication, authorization etc.).

Most of the extension methods have existed since the early days of ASP.NET Core and operate against IApplicationBuilder. But more recent version of ASP.NET Core have introduced a new WebApplication type that implements IApplicationBuilder and provides some additional functionality, notably endpoint configuration. This dichotomy makes pipelining next to impossible. In C# you don't feel the sting of this as much because of void returns. But in F# this results in an excess amount of |> ignore calls.

Let's take the hero code from the Getting Started page and add the static file middleware to it:

module Program

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder

let wapp = WebApplication.Create()

wapp.UseRouting()
    .UseDefaultFiles() // you might innocently think this is fine
    .UseStaticFiles()  // and so is this
                       // but uknowingly, the underlying type has changed
    .UseFalco([
        get "/" (Response.ofPlainText "Hello World!")
    ])
    .Run(Response.ofPlainText "Not found")
    // ^-- this is no longer starts up our application

// one way to fix this:
wapp.UseRouting() |> ignore
wapp.UseDefaultFiles().UseStaticFiles() |> ignore

wapp.UseFalco([
        get "/" (Response.ofPlainText "Hello World!")
    ])
    .Run(Response.ofPlainText "Not found")

// but we can do better

To salve this, Falco comes with a several shims. The most important of these are WebApplication.Use and WebApplication.UseIf which allow you to compose a pipeline entirely driven by WebApplication while at the same time taking advantage of the existing ASP.NET Core extensions.

wapp.UseRouting()
    .Use(fun (appl : IApplicationBuilder) ->
        appl.UseDefaultFiles()
            .UseStaticFiles())
    .UseFalco([
        get "/" (Response.ofPlainText "Hello World!")
    ])
    .Run(Response.ofPlainText "Not found")

The optional, but recommended way to take advantage of these is to utilize the static methods that server as the underpinning to the various extension methods available. The code below will attempt to highlight this more clearly:

// better yet
wapp.UseRouting()
    .Use(DefaultFilesExtensions.UseDefaultFiles)
    .Use(StaticFileExtensions.UseStaticFiles)
      // ^-- most IApplicationBuilder extensions are available as static methods similar to this
    .UseFalco([
        get "/" (Response.ofPlainText "Hello World!")
    ])
    .Run(Response.ofPlainText "Not found")

Next, we can use the UseIf extension method to conditionally add middleware to the pipeline. This is useful for things like development exception pages, or other middleware that you only want in certain environments.

let isDevelopment = wapp.Environment.EnvironmentName = "Development"
wapp.UseRouting()
    .UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage)
    .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorPage.serverException)
    .Use(DefaultFilesExtensions.UseDefaultFiles)
    .Use(StaticFileExtensions.UseStaticFiles)
    .UseFalco([
        get "/" (Response.ofPlainText "Hello World!")
    ])
    .Run(Response.ofPlainText "Not found")

This is a great way to keep your code clean and readable, while still taking advantage of the powerful middleware pipeline that ASP.NET Core provides.

Next: Deployment

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/index.html ================================================ Welcome to Falco's Documentation - Falco Documentation

Welcome to Falco's Documentation

Visit the getting started page for installation and a brief overview. There are also more detailed examples that shows how to create a small but complete application with Falco. The rest of the docs describe each component of Falco in detail.

Guides

Falco depends only on the high-performance base components of .NET and ASP.NET Core, and provides a toolset to build a working full-stack web application. This section of the documentation explains the different parts of Falco and how they can be used, customized, and extended.

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/markup.html ================================================ Markup - Falco Documentation

Markup

Falco.Markup is broken down into three primary modules, Elem, Attr and Text, which are used to generate elements, attributes and text nodes respectively. Each module contain a suite of functions mapping to the various element/attribute/node names. But can also be extended to create custom elements and attributes.

Primary elements are broken down into two types, ParentNode or SelfClosingNode.

ParentNode elements are those that can contain other elements. Represented as functions that receive two inputs: attributes and optionally elements.

Each of the primary modules can be access using the name directly, or using the "underscore syntax" seen below.

Module Syntax
Elem _h1 [] []
Attr _class_ "my-class"
Text _text "Hello world!"
Text shortcuts _h1' "Hello world" (note the trailing apostrophe)
let markup =
    _div [ _class_ "heading" ] [
        _h1' "Hello world!" ]

SelfClosingNode elements are self-closing tags. Represented as functions that receive one input: attributes.

let markup =
    _div [ _class_ "divider" ] [
        _hr [] ]

Text is represented using the TextNode and created using one of the functions in the Text module.

let markup =
    _div [] [
        _p' "A paragraph"
        _p [] [ _textf "Hello %s" "Jim" ]
        _code [] [ _textEnc "<div>Hello</div>" ] // HTML encodes text before rendering
    ]

Attributes contain two subtypes as well, KeyValueAttr which represent key/value attributes or NonValueAttr which represent boolean attributes.

let markup =
    _input [ _type_ "text"; _required_ ]

Most JavaScript Events have also been mapped in the Attr module. All of these events are prefixed with the word "on" (i.e., _onclick_, _onfocus_ etc.)

let markup =
    _button [ _onclick_ "console.log(\"hello world\")" ] [ _text "Click me" ]

HTML

Though Falco.Markup can be used to produce any markup. It is first and foremost an HTML library.

Combining views to create complex output

open Falco.Markup

// Components
let divider =
    _hr [ _class_ "divider" ]

// Template
let master (title : string) (content : XmlNode list) =
    _html [ _lang_ "en" ] [
        _head [] [
            _title [] [ _text title ]
        ]
        _body [] content
    ]

// Views
let homeView =
    master "Homepage" [
        _h1' "Homepage"
        divider
        _p' "Lorem ipsum dolor sit amet, consectetur adipiscing."
    ]

let aboutView =
    master "About Us" [
        _h1' "About"
        divider
        _p' "Lorem ipsum dolor sit amet, consectetur adipiscing."
    ]

Strongly-typed views

open Falco.Markup

type Person =
    { FirstName : string
      LastName : string }

let doc (person : Person) =
    _html [ _lang_ "en" ] [
        _head [] [
            _title [] [ _text "Sample App" ]
        ]
        _body [] [
            _main [] [
                _h1' "Sample App"
                _p' $"{person.First} {person.Last}"
            ]
        ]
    ]

Forms

Forms are the lifeblood of HTML applications. A basic form using the markup module would like the following:

let dt = DateTime.Now

_form [ _methodPost_; _action_ "/submit" ] [
    _label [ _for_' "name" ] [ _text "Name" ]
    _input [ _id_ "name"; _name_ "name"; _typeText_ ]

    _label [ _for_' "birthdate" ] [ _text "Birthday" ]
    _input [ _id_ "birthdate"; _name_ "birthdate"; _typeDate_; _valueDate_ dt ]

    _input [ _typeSubmit_ ]
]

Expanding on this, we can create a more complex form involving multiple inputs and input types as follows:

_form [ _methodPost_; _action_ "/submit" ] [
    _label [ _for_' "name" ] [ _text "Name" ]
    _input [ _id_ "name"; _name_ "name" ]

    _label [ _for_' "bio" ] [ _text "Bio" ]
    _textarea [ _name_ "id"; _name_ "bio" ] []

    _label [ _for_' "hobbies" ] [ _text "Hobbies" ]
    _select [ _id_ "hobbies"; _name_ "hobbies"; _multiple_ ] [
        _option [ _value_ "programming" ] [ _text "Programming" ]
        _option [ _value_ "diy" ] [ _text "DIY" ]
        _option [ _value_ "basketball" ] [ _text "Basketball" ]
    ]

    _fieldset [] [
        _legend [] [ _text "Do you like chocolate?" ]
        _label [] [
            _text "Yes"
            _input [ _typeRadio_; _name_ "chocolate"; _value_ "yes" ] ]
        _label [] [
            _text "No"
            _input [ _typeRadio_; _name_ "chocolate"; _value_ "no" ] ]
    ]

    _fieldset [] [
        _legend [] [ _text "Subscribe to our newsletter" ]
        _label [] [
            _text "Receive updates about product"
            _input [ _typeCheckbox_; _name_ "newsletter"; _value_ "product" ] ]
        _label [] [
            _text "Receive updates about company"
            _input [ _typeCheckbox_; _name_ "newsletter"; _value_ "company" ] ]
    ]

    _input [ _typeSubmit_ ]
]

A simple but useful meta-element _control can reduce the verbosity required to create form outputs. The same form would look like:

_form [ _methodPost_; _action_ "/submit" ] [
    _control "name" [] [ _text "Name" ]

    _controlTextarea "bio" [] [ _text "Bio" ] []

    _controlSelect "hobbies" [ _multiple_ ] [ _text "Hobbies" ] [
        _option [ _value_ "programming" ] [ _text "Programming" ]
        _option [ _value_ "diy" ] [ _text "DIY" ]
        _option [ _value_ "basketball" ] [ _text "Basketball" ]
    ]

    _fieldset [] [
        _legend [] [ _text "Do you like chocolate?" ]
        _control "chocolate" [ _id_ "chocolate_yes"; _typeRadio_ ] [ _text "yes" ]
        _control "chocolate" [ _id_ "chocolate_no"; _typeRadio_ ] [ _text "no" ]
    ]

    _fieldset [] [
        _legend [] [ _text "Subscribe to our newsletter" ]
        _control "newsletter" [ _id_ "newsletter_product"; _typeCheckbox_ ] [ _text "Receive updates about product" ]
        _control "newsletter" [ _id_ "newsletter_company"; _typeCheckbox_ ] [ _text "Receive updates about company" ]
    ]

    _input [ _typeSubmit_ ]
]

Attribute Value

One of the more common places of sytanctic complexity is with _value_ which expects, like all Attr functions, string input. Some helpers exist to simplify this.

let dt = DateTime.Now

_input [ _typeDate_; _valueStringf_ "yyyy-MM-dd" dt ]

// you could also just use:
_input [ _typeDate_; _valueDate_ dt ] // formatted to ISO-8601 yyyy-MM-dd

// or,
_input [ _typeMonth_; _valueMonth_ dt ] // formatted to ISO-8601 yyyy-MM

// or,
_input [ _typeWeek_; _valueWeek_ dt ] // formatted to Gregorian yyyy-W#

// it works for TimeSpan too:
let ts = TimeSpan(12,12,0)
_input [ _typeTime_; _valueTime_ ts ] // formatted to hh:mm

// there is a helper for Option too:
let someTs = Some ts
_input [ _typeTime_; _valueOption_ _valueTime_ someTs ]

Merging Attributes

The markup module allows you to easily create components, an excellent way to reduce code repetition in your UI. To support runtime customization, it is advisable to ensure components (or reusable markup blocks) retain a similar function "shape" to standard elements. That being, XmlAttribute list -> XmlNode list -> XmlNode.

This means that you will inevitably end up needing to combine your predefined XmlAttribute list with a list provided at runtime. To facilitate this, the Attr.merge function will group attributes by key, and intelligently concatenate the values in the case of additive attributes (i.e., class, style and accept).

open Falco.Markup

// Components
let heading (attrs : XmlAttribute list) (content : XmlNode list) =
    // safely combine the default XmlAttribute list with those provided
    // at runtime
    let attrs' =
        Attr.merge [ _class_ "text-large" ] attrs

    _div [] [
        _h1 [ attrs' ] content
    ]

// Template
let master (title : string) (content : XmlNode list) =
    _html [ _lang_ "en" ] [
        _head [] [
            _title [] [ _text title ]
        ]
        _body [] content
    ]

// Views
let homepage =
    master "Homepage" [
        heading [ _class_ "red" ] [ _text "Welcome to the homepage" ]
        _p' "Lorem ipsum dolor sit amet, consectetur adipiscing."
    ]

let homepage =
    master "About Us" [
        heading [ _class_ "purple" ] [ _text "This is what we're all about" ]
        _p' "Lorem ipsum dolor sit amet, consectetur adipiscing."
    ]

Custom Elements & Attributes

Every effort has been taken to ensure the HTML and SVG specs are mapped to functions in the module. In the event an element or attribute you need is missing, you can either file an issue, or more simply extend the module in your project.

An example creating custom XML elements and using them to create a structured XML document:

open Falco.Makrup

module XmlElem =
    let books = Attr.create "books"
    let book = Attr.create "book"
    let name = Attr.create "name"

module XmlAttr =
    let soldOut = Attr.createBool "soldOut"

let xmlDoc =
    XmlElem.books [] [
        XmlElem.book [ XmlAttr.soldOut ] [
            XmlElem.name [] [ _text "To Kill A Mockingbird" ]
        ]
    ]

let xml = renderXml xmlDoc

Template Fragments

There are circumstances where you may want to render only a portion of your view. Especially common in hypermedia driven applications. Supporting template fragments is helpful in maintaining locality of behaviour, because it allows you to decompose a particular view for partial updates internally without pulling fragments of the template out to separate files for rendering, creating a large number of individual templates.

Falco.Markup supports this pattern by way of the renderFragment function, which will traverse the provided XmlNode tree and render only the child node matching the provided id. Otherwise, gracefully returning an empty string if no match is found.

open Falco.Markup

let view =
    _div [ _id_ "my-div"; _class_ "my-class" ] [
        _h1 [ _id_ "my-heading" ] [ _text "hello" ] ]

let render = renderFragment doc "my-heading"
// produces: <h1 id="my-heading">hello</h1>

SVG

Much of the SVG spec has been mapped to element and attributes functions. There is also an SVG template to help initialize a new drawing with a valid viewbox.

open Falco.Markup
open Falco.Markup.Svg

// https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text#example
let svgDrawing =
    Templates.svg (0, 0, 240, 80) [
        _style [] [
            _text ".small { font: italic 13px sans-serif; }"
            _text ".heavy { font: bold 30px sans-serif; }"
            _text ".Rrrrr { font: italic 40px serif; fill: red; }"
        ]
        _text [ _x_ "20"; _y_ "35"; _class_ "small" ] [ _text "My" ]
        _text [ _x_ "40"; _y_ "35"; _class_ "heavy" ] [ _text "cat" ]
        _text [ _x_ "55"; _y_ "55"; _class_ "small" ] [ _text "is" ]
        _text [ _x_ "65"; _y_ "55"; _class_ "Rrrrr" ] [ _text "Grumpy!" ]
    ]

let svg = renderNode svgDrawing

Next: Cross-site Request Forgery (XSRF)

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/migrating-from-v4-to-v5.html ================================================ Migrating from v4.x to v5.x - Falco Documentation

Migrating from v4.x to v5.x

With Falco v5.x the main objective was to simplify the API and improve the overall devlopment experience long term. The idea being provide only what is necessary, or provides the most value in the most frequently developed areas.

This document will attempt to cover the anticipated transformations necessary to upgrade from v4.x to v5.x. Pull requests are welcome for missing scenarios, thank you in advance for your help.

webHost expression

Perhaps the most significant change is the removal of the webHost expression, which attempted to make web application server construction more pleasant. Microsoft has made really nice strides in this area (i.e., WebApplication) and it's been difficult at times to stay sync with the breaking changes to the underlying interfaces. As such, we elected to remove it altogether.

Below demonstrates how to migrate a "hello world" app from v4 to v5 by replacing the webHost expression with the Microsoft provided WebApplicationBuilder.

// Falco v4.x
open Falco

webHost args {

    use_static_files

    endpoints [
        get "/"
            (Response.ofPlainText "hello world")
    ]
}
// Falco v5.x
open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder
// ^-- this import adds many useful extensions

let endpoints =
    [
        get "/" (Response.ofPlainText "Hello World!")
        // ^-- associate GET / to plain text HttpHandler
    ]

let wapp = WebApplication.Create()

wapp.UseRouting()
    .UseFalco(endpoints)
    // ^-- activate Falco endpoint source
    .Run()

configuration expression

The configuration expression has also been removed. Again, the idea being to try and get in the way of potentially evolving APIs as much as possible. Even more so in the areas where the code was mostly decorative.

Note: This example is entirely trivial since the WebApplication.CreateBuilder() configures a host with common, sensible defaults.

open Falco
open Falco.HostBuilder

let config = configuration [||] {
    required_json "appsettings.json"
    optional_json "appsettings.Development.json"
}

webHost [||] {
    endpoints []
}
open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Configuration
// ^-- this import adds access to Configuration

let bldr = WebApplication.CreateBuilder()
let conf =
    bldr.Configuration
        .AddJsonFile("appsettings.json", optional = false)
        .AddJsonFile("appsettings.Development.json")

let wapp = WebApplication.Create()

let endpoints = []

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

StringCollectionReader replaced by RequestData

For the most part, this upgrade won't require any changes for the end user. Especially if the continuation-style functions in the Request module were used.

Explicit references to: CookieCollectionReader, HeaderCollectionReader, RouteCollectionReader, QueryCollectionReader will need to be updated to RequestData. FormCollectionReader has been replaced by FormData.

Form Streaming

Falco now automatically detects whether the form is transmiting multipart/form-data, which means deprecating the Request module streaming functions.

  • Request.streamForm becomes -> Request.mapForm
  • Request.streamFormSecure becomes -> Request.mapFormSecure
  • Request.mapFormStream becomes -> Request.mapForm
  • Request.mapFormStreamSecure becomes -> Request.mapFormSecure

Removed Services.inject<'T1 .. 'T5>

This type was removed because it continued to pose problems for certain code analysis tools. To continue using the service locator pattern, you can now use the more versatile HttpContext extension method ctx.Plug<T>(). For example:

let myHandler : HttpHandler =
    Services.inject<MyService> (fun myService ctx ->
        let message = myService.CreateMessage()
        Response.ofPlainText $"{message}" ctx)

// becomes
let myHandler : HttpHandler = fun ctx ->
    let myService = ctx.Plug<MyService>()
    let message = myService.CreateMessage()
    Response.ofPlainText $"{message}" ctx

Xss module renamed to Xsrf

The Xss module has been renamed to Xsrf to better describe it's intent.

    //before: Xss.antiforgeryInput
    Xsrf.antiforgeryInput // ..

    //before: Xss.getToken
    Xsrf.getToken // ..

    //before: Xss.validateToken
    Xsrf.validateToken // ..

Crypto module removed

The Crypto module provided functionality for: random numbers, salt generation and key derivation. The code in this module was really a veneer on top of the cryptographic providers in the base library. Extracting this code into your project would be dead simple. The source is permalinked here for such purposes.

Auth module removed

The Auth module functionality was ported one-to-one to the Response module.

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/request.html ================================================ Request Handling - Falco Documentation

Request Handling

Falco exposes a uniform API to obtain typed values from IFormCollection, IQueryCollection, RouteValueDictionary, IHeaderCollection, and IRequestCookieCollection. This is achieved by means of the RequestData type and it's derivative FormData. These abstractions are intended to make it easier to work with the url-encoded key/value collections.

Take note of the similarities when interacting with the different sources of request data.

A brief aside on the key/value semantics

RequestData is supported by a recursive discriminated union called RequestValue which represents a parsed key/value collection.

The RequestValue parsing process provides some simple, yet powerful, syntax to submit objects and collections over-the-wire, to facilitate complex form and query submissions.

Key Syntax: Object Notation

Keys using dot notation are interpreted as complex (i.e., nested values) objects.

Consider the following POST request:

POST /my-form HTTP/1.1
Host: foo.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 46

user.name=john%20doe&user.email=abc@def123.com

This will be intepreted as the following RequestValue:

RObject [
    "user", RObject [
        "name", RString "john doe"
        "email", RString "abc@def123.com"
    ]
]

See form binding for details on interacting with form data.

Key Syntax: List Notation

Keys using square bracket notation are interpreted as lists, which can include both primitives and complex objects. Both indexed and non-indexed variants are supported.

Consider the following request:

GET /my-search?name=john&season[0]=summer&season[1]=winter&hobbies[]=hiking HTTP/1.1
Host: foo.example
Content-Type: application/x-www-form-urlencoded
Content-Length: 68

This will be interpreted as the following RequestValue:

RObject [
    "name", RString "john"
    "season", RList [ RString "summer"; RString "winter" ]
    "hobbies", RList [ RString "hking" ]
]

See query binding for details on interacting with form data.

Request Data Access

RequestData provides the ability to safely read primitive types from flat and nested key/value collections.

let requestData : RequestData = // From: Route | Query | Form

// Retrieve primitive options
let str : string option = requestData.TryGetString "name"
let flt : float option = requestData.TryGetFloat "temperature"

// Retrieve primitive, or default
let str : string = requestData.GetString "name"
let strOrDefault : string = requestData.GetString ("name", "John Doe")
let flt : float = requestData.GetFloat "temperature"

// Retrieve primitive list
let strList : string list = requestData.GetStringList "hobbies"
let grades : int list = requestData.GetInt32List "grades"

// Dynamic access, useful for nested/complex collections
// Equivalent to:
// requestData.Get("user").Get("email_address").AsString()
let userEmail = requestData?user?email_address.AsString()

Route Binding

Provides access to the values found in the RouteValueDictionary.

open Falco

// Assuming a route pattern of /{Name}
let manualRouteHandler : HttpHandler = fun ctx ->
    let r = Request.getRoute ctx
    let name = r.GetString "Name"
    // Or, let name = r?Name.AsString()
    // Or, let name = r.TryGetString "Name" |> Option.defaultValue ""
    Response.ofPlainText name ctx

let mapRouteHandler : HttpHandler =
    Request.mapRoute (fun r ->
        r.GetString "Name")
        Response.ofPlainText

Query Binding

Provides access to the values found in the IQueryCollection, as well as the RouteValueDictionary. In the case of matching keys, the values in the IQueryCollection take precedence.

open Falco

type Person =
    { FirstName : string
      LastName : string }

let form : HttpHandler =
    Response.ofHtmlCsrf view

let manualQueryHandler : HttpHandler = fun ctx ->
    let q = Request.getQuery ctx

    let person =
        { FirstName = q.GetString ("FirstName", "John") // Get value or return default value
          LastName  = q.GetString ("LastName", "Doe") }

    Response.ofJson person ctx

let mapQueryHandler : HttpHandler =
    Request.mapQuery (fun q ->
        let first = q.GetString ("FirstName", "John") // Get value or return default value
        let last = q.GetString ("LastName", "Doe")
        { FirstName = first; LastName = last })
        Response.ofJson

Form Binding

Provides access to the values found in he IFormCollection, as well as the RouteValueDictionary. In the case of matching keys, the values in the IFormCollection take precedence.

The FormData inherits from RequestData type also exposes the IFormFilesCollection via the _.Files member and _.TryGetFile(name : string) method.

type Person =
    { FirstName : string
      LastName : string }

let manualFormHandler : HttpHandler = fun ctx ->
    task {
        let! f : FormData = Request.getForm ctx

        let person =
            { FirstName = f.GetString ("FirstName", "John") // Get value or return default value
              LastName = f.GetString ("LastName", "Doe") }

        return! Response.ofJson person ctx
    }

let mapFormHandler : HttpHandler =
    Request.mapForm (fun f ->
        let first = f.GetString ("FirstName", "John") // Get value or return default value
        let last = f.GetString ("LastName", "Doe")
        { FirstName = first; LastName = last })
        Response.ofJson

let mapFormSecureHandler : HttpHandler =
    Request.mapFormSecure (fun f -> // `Request.mapFormSecure` will automatically validate CSRF token for you.
        let first = f.GetString ("FirstName", "John") // Get value or return default value
        let last = f.GetString ("LastName", "Doe")
        { FirstName = first; LastName = last })
        Response.ofJson
        (Response.withStatusCode 400 >> Response.ofEmpty)

multipart/form-data Binding

Microsoft defines large upload as anything > 64KB, which well... is most uploads. Anything beyond this size and they recommend streaming the multipart data to avoid excess memory consumption.

To make this process a lot easier Falco's form handlers will attempt to stream multipart form-data, or return an error message indicating the likely problem.

let imageUploadHandler : HttpHandler =
    let formBinder (f : FormData) : IFormFile option =
        f.TryGetFormFile "profile_image"

    let uploadImage (profileImage : IFormFile option) : HttpHandler =
        // Process the uploaded file ...

    // Safely buffer the multipart form submission
    Request.mapForm formBinder uploadImage

let secureImageUploadHandler : HttpHandler =
    let formBinder (f : FormData) : IFormFile option =
        f.TryGetFormFile "profile_image"

    let uploadImage (profileImage : IFormFile option) : HttpHandler =
        // Process the uploaded file ...

    let handleInvalidCsrf : HttpHandler =
        Response.withStatusCode 400 >> Response.ofEmpty

    // Safely buffer the multipart form submission
    Request.mapFormSecure formBinder uploadImage handleInvalidCsrf

JSON

These handlers use the .NET built-in System.Text.Json.JsonSerializer.

type Person =
    { FirstName : string
      LastName : string }

let jsonHandler : HttpHandler =
    Response.ofJson {
        FirstName = "John"
        LastName = "Doe" }

let mapJsonHandler : HttpHandler =
    let handleOk person : HttpHandler =
        let message = sprintf "hello %s %s" person.First person.Last
        Response.ofPlainText message

    Request.mapJson handleOk

let mapJsonOptionsHandler : HttpHandler =
    let options = JsonSerializerOptions()
    options.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull

    let handleOk person : HttpHandler =
        let message = sprintf "hello %s %s" person.First person.Last
        Response.ofPlainText message

    Request.mapJsonOption options handleOk

Next: View engine

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/response.html ================================================ Response Writing - Falco Documentation

Response Writing

The HttpHandler type is used to represent the processing of a request. It can be thought of as the eventual (i.e., asynchronous) completion and processing of an HTTP request, defined in F# as: HttpContext -> Task. Handlers will typically involve some combination of: route inspection, form/query binding, business logic and finally response writing. With access to the HttpContext you are able to inspect all components of the request, and manipulate the response in any way you choose.

Plain Text responses

let textHandler : HttpHandler =
    Response.ofPlainText "hello world"

HTML responses

Write your views in plain F#, directly in your assembly, using the Markup module. A performant F# DSL capable of generating any angle-bracket markup. Also available directly as a standalone NuGet package.

let htmlHandler : HttpHandler =
    let html =
        _html [ _lang_ "en" ] [
            _head [] []
            _body [] [
                _h1' "Sample App" // shorthand for: `_h1 [] [ Text.raw "Sample App" ]`
            ]
        ]

    Response.ofHtml html

// Automatically protect against XSS attacks
let secureHtmlHandler : HttpHandler =
    let html token =
        _html [] [
            _body [] [
                _form [ _method_ "post" ] [
                    _input [ _name_ "first_name" ]
                    _input [ _name_ "last_name" ]
                    // using the CSRF HTML helper
                    Xsrf.antiforgeryInput token
                    _input [ _type_ "submit"; _value_ "Submit" ]
                ]
            ]
        ]

    Response.ofHtmlCsrf html

Alternatively, if you're using an external view engine and want to return an HTML response from a string literal, then you can use Response.ofHtmlString.

let htmlHandler : HttpHandler =
    Response.ofHtmlString "<html>...</html>"

Template Fragments

If you want to return a fragment of HTML, for example when working with htmx, you can use Response.ofFragment (or Response.ofFragmentCsrf). This function takes an element ID as its first argument, and a XmlNode as its second argument. The server will return only the contents of the node with the specified ID.

let fragmentHandler : HttpHandler =
    let html =
        _div [ _id_ "greeting" ] [
            _h1 [ _id_ "heading" ] [ _text "Hello, World!" ]
        ]

    Response.ofFragment "heading" html

This will return only the contents of the h1 element, i.e. <h1 id="heading">Hello, World!</h1>. In the case of multiple elements with the same ID, the first one found will be returned. If no element with the specified ID is found, an empty response will be returned.

JSON responses

These handlers use the .NET built-in System.Text.Json.JsonSerializer.

type Person =
    { First : string
      Last  : string }

let jsonHandler : HttpHandler =
    let name = { First = "John"; Last = "Doe" }
    Response.ofJson name

let jsonOptionsHandler : HttpHandler =
    let options = JsonSerializerOptions()
    options.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull
    let name = { First = "John"; Last = "Doe" }
    Response.ofJsonOptions options name

Redirect (301/302) Response

let oldUrlHandler : HttpHandler =
    Response.redirectPermanently "/new-url" // HTTP 301

let redirectUrlHandler : HttpHandler =
    Response.redirectTemporarily "/new-url" // HTTP 302

Content Disposition

let inlineBinaryHandler : HttpHandler =
    let contentType = "image/jpeg"
    let headers = [ HeaderNames.CacheControl,  "no-store, max-age=0" ]
    let bytes = // ... binary data
    Response.ofBinary contentType headers bytes

let attachmentHandler : HttpHandler =
    let filename = "profile.jpg"
    let contentType = "image/jpeg"
    let headers = [ HeaderNames.CacheControl,  "no-store, max-age=0" ]
    let bytes = // ... binary data
    Response.ofAttachment filename contentType headers bytes

Response Modifiers

Response modifiers can be thought of as the in-and-out modification of the HttpResponse. A preamble to writing and returning. Since these functions receive the Httpcontext as input and return it as the only output, they can take advantage of function compoistion.

Set the status code of the response

let notFoundHandler : HttpHandler =
    Response.withStatusCode 404
    >> Response.ofPlainText "Not found"

Add a header(s) to the response

let handlerWithHeaders : HttpHandler =
    Response.withHeaders [ "Content-Language", "en-us" ]
    >> Response.ofPlainText "Hello world"

IMPORTANT: Do not use this for authentication. Instead use the Response.signInAndRedirect and Response.signOutAndRedirect functions found in the Authentication module.

let handlerWithCookie : HttpHandler =
    Response.withCookie "greeted" "1"
    >> Response.ofPlainText "Hello world"

let handlerWithCookieOptions : HttpHandler =
    let options = CookieOptions()
    options.Expires <- DateTime.Now.Minutes(15)
    Response.withCookie options "greeted" "1"
    >> Response.ofPlainText "Hello world"

Next: Request Handling

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/docs/routing.html ================================================ Routing - Falco Documentation

Routing

Routing is responsible for matching incoming HTTP requests and dispatching those requests to the app's HttpHandlers. The breakdown of Endpoint Routing is simple. Associate a specific route pattern and an HTTP verb to an HttpHandler which represents the ongoing processing (and eventual return) of a request.

Bearing this in mind, routing can practically be represented by a list of these "mappings" known in Falco as an HttpEndpoint which bind together: a route, verb and handler.

Note: All of the following examples are fully functioning web apps.

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder

let wapp = WebApplication.Create()

let endpoints =
    [ get "/" (Response.ofPlainText "hello world") ]

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

The preceding example includes a single HttpEndpoint:

  • When an HTTP GET request is sent to the root URL /:
    • The HttpHandler shown executes.
    • Hello World! is written to the HTTP response using the Response module.
  • If the request method is not GET or the URL is not /, no route matches and an HTTP 404 is returned.

The following example shows a more sophisticated HttpEndpoint:

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder

let wapp = WebApplication.Create()

let endpoints =
    [
        get "/hello/{name:alpha}" (fun ctx ->
            let route = Request.getRoute ctx
            let name = route.GetString "name"
            let message = sprintf "Hello %s" name
            Response.ofPlainText message ctx)
    ]

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

The string /hello/{name:alpha} is a route template. It is used to configure how the endpoint is matched. In this case, the template matches:

The second segment of the URL path, {name:alpha}:

  • Is bound to the name parameter.
  • Is captured and stored in HttpRequest.RouteValues, which Falco exposes through a uniform API to obtain primitive typed values.

An alternative way to express the HttEndpoint above is seen below.

open Falco
open Falco.Routing
open Microsoft.AspNetCore.Builder

let wapp = WebApplication.Create()

let greetingHandler name : HttpHandler =
    let message = sprintf "Hello %s" name
    Response.ofPlainText message

let endpoints =
    [ mapGet "/hello/{name:alpha}" (fun route -> route.GetString "name") greetingHandler ]

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

Multi-method Endpoints

There are scenarios where you may want to accept multiple HTTP verbs to single a URL. For example, a GET/POST form submission.

To create a "multi-method" endpoint, the all function accepts a list of HTTP Verb and HttpHandler pairs.

open Falco
open Falco.Markup
open Microsoft.AspNetCore.Builder

let form =
    Templates.html5 "en" [] [
        _form [ _method_ "post" ] [
            _input [ _name_ "name" ]
            _input [ _type_ "submit" ] ] ]

let wapp = WebApplication.Create()

let endpoints =
    [
        get "/" (Response.ofPlainText "Hello from /")
        all "/form" [
            GET, Response.ofHtml form
            POST, Response.ofEmpty ]
    ]

wapp.UseRouting()
    .UseFalco(endpoints)
    .Run()

Next: Response Writing

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/index.html ================================================ Falco - F# web toolkit for ASP.NET Core

Meet Falco.

Falco is a toolkit for building fast and functional-first web applications using F#.

Fast & Lightweight

Optimized for speed and low memory usage.
Learn More

Easy to Learn

Simple, predictable, and easy to pick up.
Get Started

Native View Engine

Markup is written in F# and compiled.
See Examples

Customizable

Seamlessly integrates with ASP.NET.
Explore How

NuGet Version build

open Falco
open Microsoft.AspNetCore.Builder

let wapp = WebApplication.Create()

wapp.Run(Response.ofPlainText "Hello world")

Falco is a toolkit for building functional-first, full-stack web applications using F#.

  • Built on the high-performance components of ASP.NET Core.
  • Seamlessly integrates with existing .NET Core middleware and libraries.
  • Designed to be simple, lightweight and easy to learn.

Key Features

Design Goals

  • Provide a toolset to build full-stack web application in F#.
  • Should be simple, extensible and integrate with existing .NET libraries.
  • Can be easily learned.

Learn

The best way to get started is by visiting the documentation. For questions and support please use discussions. For chronological updates refer to the changelog is the best place to find chronological updates.

Community Projects

Articles

Videos

Contribute

We kindly ask that before submitting a pull request, you first submit an issue or open a discussion.

If functionality is added to the API, or changed, please kindly update the relevant document. Unit tests must also be added and/or updated before a pull request can be successfully merged.

Only pull requests which pass all build checks and comply with the general coding standard can be approved.

If you have any further questions, submit an issue or open a discussion or reach out on Twitter.

Why "Falco"?

Kestrel has been a game changer for the .NET web stack. In the animal kingdom, "Kestrel" is a name given to several members of the falcon genus. Also known as "Falco".

Find a bug?

There's an issue for that.

License

Licensed under Apache License 2.0.

© 2020-2025 Pim Brouwers & contributors.
================================================ FILE: docs/prism.css ================================================ code[class*="language-"],pre[class*="language-"]{color:#ccc;background:none;font-family:Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*="language-"]{padding:1em;margin:0.5em 0;overflow:auto}:not(pre) > code[class*="language-"],pre[class*="language-"]{background:#2d2d2d}:not(pre) > code[class*="language-"]{padding:0.1em;border-radius:0.3em;white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:bold}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}div.code-toolbar{position:relative}div.code-toolbar > .toolbar{position:absolute;top:0.3em;right:0.2em;transition:opacity 0.3s ease-in-out;opacity:0}div.code-toolbar:hover > .toolbar{opacity:1}div.code-toolbar:focus-within > .toolbar{opacity:1}div.code-toolbar > .toolbar .toolbar-item{display:inline-block}div.code-toolbar > .toolbar a{cursor:pointer}div.code-toolbar > .toolbar button{background:none;border:0;color:inherit;font:inherit;line-height:normal;overflow:visible;padding:0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none}div.code-toolbar > .toolbar a,div.code-toolbar > .toolbar button,div.code-toolbar > .toolbar span{color:#bbb;font-size:0.8em;padding:0 0.5em;background:#f5f2f0;background:rgba(224, 224, 224, 0.2);box-shadow:0 2px 0 0 rgba(0,0,0,0.2);border-radius:0.5em}div.code-toolbar > .toolbar a:focus,div.code-toolbar > .toolbar a:hover,div.code-toolbar > .toolbar button:focus,div.code-toolbar > .toolbar button:hover,div.code-toolbar > .toolbar span:focus,div.code-toolbar > .toolbar span:hover{color:inherit;text-decoration:none} ================================================ FILE: docs/prism.js ================================================ /* PrismJS 1.22.0 https://prismjs.com/download.html#themes=prism-tomorrow&languages=css+clike+csharp+fsharp+powershell+sql&plugins=toolbar */ var _self="undefined"!=typeof window?window:"undefined"!=typeof WorkerGlobalScope&&self instanceof WorkerGlobalScope?self:{},Prism=function(u){var c=/\blang(?:uage)?-([\w-]+)\b/i,n=0,_={manual:u.Prism&&u.Prism.manual,disableWorkerMessageHandler:u.Prism&&u.Prism.disableWorkerMessageHandler,util:{encode:function e(n){return n instanceof M?new M(n.type,e(n.content),n.alias):Array.isArray(n)?n.map(e):n.replace(/&/g,"&").replace(/=l.reach);y+=m.value.length,m=m.next){var k=m.value;if(t.length>n.length)return;if(!(k instanceof M)){var b,x=1;if(h){if(!(b=W(p,y,n,f)))break;var w=b.index,A=b.index+b[0].length,P=y;for(P+=m.value.length;P<=w;)m=m.next,P+=m.value.length;if(P-=m.value.length,y=P,m.value instanceof M)continue;for(var S=m;S!==t.tail&&(Pl.reach&&(l.reach=N);var j=m.prev;O&&(j=z(t,j,O),y+=O.length),I(t,j,x);var C=new M(o,g?_.tokenize(E,g):E,d,E);m=z(t,j,C),L&&z(t,m,L),1"+a.content+""},!u.document)return u.addEventListener&&(_.disableWorkerMessageHandler||u.addEventListener("message",function(e){var n=JSON.parse(e.data),t=n.language,r=n.code,a=n.immediateClose;u.postMessage(_.highlight(r,_.languages[t],t)),a&&u.close()},!1)),_;var e=_.util.currentScript();function t(){_.manual||_.highlightAll()}if(e&&(_.filename=e.src,e.hasAttribute("data-manual")&&(_.manual=!0)),!_.manual){var r=document.readyState;"loading"===r||"interactive"===r&&e&&e.defer?document.addEventListener("DOMContentLoaded",t):window.requestAnimationFrame?window.requestAnimationFrame(t):window.setTimeout(t,16)}return _}(_self);"undefined"!=typeof module&&module.exports&&(module.exports=Prism),"undefined"!=typeof global&&(global.Prism=Prism); !function(e){var t=/("|')(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/;e.languages.css={comment:/\/\*[\s\S]*?\*\//,atrule:{pattern:/@[\w-]+[\s\S]*?(?:;|(?=\s*\{))/,inside:{rule:/^@[\w-]+/,"selector-function-argument":{pattern:/(\bselector\s*\((?!\s*\))\s*)(?:[^()]|\((?:[^()]|\([^()]*\))*\))+?(?=\s*\))/,lookbehind:!0,alias:"selector"},keyword:{pattern:/(^|[^\w-])(?:and|not|only|or)(?![\w-])/,lookbehind:!0}}},url:{pattern:RegExp("\\burl\\((?:"+t.source+"|(?:[^\\\\\r\n()\"']|\\\\[^])*)\\)","i"),greedy:!0,inside:{function:/^url/i,punctuation:/^\(|\)$/,string:{pattern:RegExp("^"+t.source+"$"),alias:"url"}}},selector:RegExp("[^{}\\s](?:[^{};\"']|"+t.source+")*?(?=\\s*\\{)"),string:{pattern:t,greedy:!0},property:/[-_a-z\xA0-\uFFFF][-\w\xA0-\uFFFF]*(?=\s*:)/i,important:/!important\b/i,function:/[-a-z0-9]+(?=\()/i,punctuation:/[(){};:,]/},e.languages.css.atrule.inside.rest=e.languages.css;var s=e.languages.markup;s&&(s.tag.addInlined("style","css"),e.languages.insertBefore("inside","attr-value",{"style-attr":{pattern:/(^|["'\s])style\s*=\s*(?:"[^"]*"|'[^']*')/i,lookbehind:!0,inside:{"attr-value":{pattern:/=\s*(?:"[^"]*"|'[^']*'|[^\s'">=]+)/,inside:{style:{pattern:/(["'])[\s\S]+(?=["']$)/,lookbehind:!0,alias:"language-css",inside:e.languages.css},punctuation:[{pattern:/^=/,alias:"attr-equals"},/"|'/]}},"attr-name":/^style/i}}},s.tag))}(Prism); Prism.languages.clike={comment:[{pattern:/(^|[^\\])\/\*[\s\S]*?(?:\*\/|$)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0,greedy:!0}],string:{pattern:/(["'])(?:\\(?:\r\n|[\s\S])|(?!\1)[^\\\r\n])*\1/,greedy:!0},"class-name":{pattern:/(\b(?:class|interface|extends|implements|trait|instanceof|new)\s+|\bcatch\s+\()[\w.\\]+/i,lookbehind:!0,inside:{punctuation:/[.\\]/}},keyword:/\b(?:if|else|while|do|for|return|in|instanceof|function|new|try|throw|catch|finally|null|break|continue)\b/,boolean:/\b(?:true|false)\b/,function:/\w+(?=\()/,number:/\b0x[\da-f]+\b|(?:\b\d+\.?\d*|\B\.\d+)(?:e[+-]?\d+)?/i,operator:/[<>]=?|[!=]=?=?|--?|\+\+?|&&?|\|\|?|[?*/~^%]/,punctuation:/[{}[\];(),.:]/}; !function(s){function a(e,s){return e.replace(/<<(\d+)>>/g,function(e,n){return"(?:"+s[+n]+")"})}function t(e,n,s){return RegExp(a(e,n),s||"")}function e(e,n){for(var s=0;s>/g,function(){return"(?:"+e+")"});return e.replace(/<>/g,"[^\\s\\S]")}var n="bool byte char decimal double dynamic float int long object sbyte short string uint ulong ushort var void",i="class enum interface struct",r="add alias and ascending async await by descending from get global group into join let nameof not notnull on or orderby partial remove select set unmanaged value when where",o="abstract as base break case catch checked const continue default delegate do else event explicit extern finally fixed for foreach goto if implicit in internal is lock namespace new null operator out override params private protected public readonly ref return sealed sizeof stackalloc static switch this throw try typeof unchecked unsafe using virtual volatile while yield";function l(e){return"\\b(?:"+e.trim().replace(/ /g,"|")+")\\b"}var d=l(i),p=RegExp(l(n+" "+i+" "+r+" "+o)),c=l(i+" "+r+" "+o),u=l(n+" "+i+" "+o),g=e("<(?:[^<>;=+\\-*/%&|^]|<>)*>",2),b=e("\\((?:[^()]|<>)*\\)",2),h="@?\\b[A-Za-z_]\\w*\\b",f=a("<<0>>(?:\\s*<<1>>)?",[h,g]),m=a("(?!<<0>>)<<1>>(?:\\s*\\.\\s*<<1>>)*",[c,f]),k="\\[\\s*(?:,\\s*)*\\]",y=a("<<0>>(?:\\s*(?:\\?\\s*)?<<1>>)*(?:\\s*\\?)?",[m,k]),w=a("(?:<<0>>|<<1>>)(?:\\s*(?:\\?\\s*)?<<2>>)*(?:\\s*\\?)?",[a("\\(<<0>>+(?:,<<0>>+)+\\)",[a("[^,()<>[\\];=+\\-*/%&|^]|<<0>>|<<1>>|<<2>>",[g,b,k])]),m,k]),v={keyword:p,punctuation:/[<>()?,.:[\]]/},x="'(?:[^\r\n'\\\\]|\\\\.|\\\\[Uux][\\da-fA-F]{1,8})'",$='"(?:\\\\.|[^\\\\"\r\n])*"';s.languages.csharp=s.languages.extend("clike",{string:[{pattern:t("(^|[^$\\\\])<<0>>",['@"(?:""|\\\\[^]|[^\\\\"])*"(?!")']),lookbehind:!0,greedy:!0},{pattern:t("(^|[^@$\\\\])<<0>>",[$]),lookbehind:!0,greedy:!0},{pattern:RegExp(x),greedy:!0,alias:"character"}],"class-name":[{pattern:t("(\\busing\\s+static\\s+)<<0>>(?=\\s*;)",[m]),lookbehind:!0,inside:v},{pattern:t("(\\busing\\s+<<0>>\\s*=\\s*)<<1>>(?=\\s*;)",[h,w]),lookbehind:!0,inside:v},{pattern:t("(\\busing\\s+)<<0>>(?=\\s*=)",[h]),lookbehind:!0},{pattern:t("(\\b<<0>>\\s+)<<1>>",[d,f]),lookbehind:!0,inside:v},{pattern:t("(\\bcatch\\s*\\(\\s*)<<0>>",[m]),lookbehind:!0,inside:v},{pattern:t("(\\bwhere\\s+)<<0>>",[h]),lookbehind:!0},{pattern:t("(\\b(?:is(?:\\s+not)?|as)\\s+)<<0>>",[y]),lookbehind:!0,inside:v},{pattern:t("\\b<<0>>(?=\\s+(?!<<1>>)<<2>>(?:\\s*[=,;:{)\\]]|\\s+(?:in|when)\\b))",[w,u,h]),inside:v}],keyword:p,number:/(?:\b0(?:x[\da-f_]*[\da-f]|b[01_]*[01])|(?:\B\.\d+(?:_+\d+)*|\b\d+(?:_+\d+)*(?:\.\d+(?:_+\d+)*)?)(?:e[-+]?\d+(?:_+\d+)*)?)(?:ul|lu|[dflmu])?\b/i,operator:/>>=?|<<=?|[-=]>|([-+&|])\1|~|\?\?=?|[-+*/%&|^!=<>]=?/,punctuation:/\?\.?|::|[{}[\];(),.:]/}),s.languages.insertBefore("csharp","number",{range:{pattern:/\.\./,alias:"operator"}}),s.languages.insertBefore("csharp","punctuation",{"named-parameter":{pattern:t("([(,]\\s*)<<0>>(?=\\s*:)",[h]),lookbehind:!0,alias:"punctuation"}}),s.languages.insertBefore("csharp","class-name",{namespace:{pattern:t("(\\b(?:namespace|using)\\s+)<<0>>(?:\\s*\\.\\s*<<0>>)*(?=\\s*[;{])",[h]),lookbehind:!0,inside:{punctuation:/\./}},"type-expression":{pattern:t("(\\b(?:default|typeof|sizeof)\\s*\\(\\s*)(?:[^()\\s]|\\s(?!\\s*\\))|<<0>>)*(?=\\s*\\))",[b]),lookbehind:!0,alias:"class-name",inside:v},"return-type":{pattern:t("<<0>>(?=\\s+(?:<<1>>\\s*(?:=>|[({]|\\.\\s*this\\s*\\[)|this\\s*\\[))",[w,m]),inside:v,alias:"class-name"},"constructor-invocation":{pattern:t("(\\bnew\\s+)<<0>>(?=\\s*[[({])",[w]),lookbehind:!0,inside:v,alias:"class-name"},"generic-method":{pattern:t("<<0>>\\s*<<1>>(?=\\s*\\()",[h,g]),inside:{function:t("^<<0>>",[h]),generic:{pattern:RegExp(g),alias:"class-name",inside:v}}},"type-list":{pattern:t("\\b((?:<<0>>\\s+<<1>>|where\\s+<<2>>)\\s*:\\s*)(?:<<3>>|<<4>>)(?:\\s*,\\s*(?:<<3>>|<<4>>))*(?=\\s*(?:where|[{;]|=>|$))",[d,f,h,w,p.source]),lookbehind:!0,inside:{keyword:p,"class-name":{pattern:RegExp(w),greedy:!0,inside:v},punctuation:/,/}},preprocessor:{pattern:/(^\s*)#.*/m,lookbehind:!0,alias:"property",inside:{directive:{pattern:/(\s*#)\b(?:define|elif|else|endif|endregion|error|if|line|pragma|region|undef|warning)\b/,lookbehind:!0,alias:"keyword"}}}});var _=$+"|"+x,B=a("/(?![*/])|//[^\r\n]*[\r\n]|/\\*(?:[^*]|\\*(?!/))*\\*/|<<0>>",[_]),E=e(a("[^\"'/()]|<<0>>|\\(<>*\\)",[B]),2),R="\\b(?:assembly|event|field|method|module|param|property|return|type)\\b",P=a("<<0>>(?:\\s*\\(<<1>>*\\))?",[m,E]);s.languages.insertBefore("csharp","class-name",{attribute:{pattern:t("((?:^|[^\\s\\w>)?])\\s*\\[\\s*)(?:<<0>>\\s*:\\s*)?<<1>>(?:\\s*,\\s*<<1>>)*(?=\\s*\\])",[R,P]),lookbehind:!0,greedy:!0,inside:{target:{pattern:t("^<<0>>(?=\\s*:)",[R]),alias:"keyword"},"attribute-arguments":{pattern:t("\\(<<0>>*\\)",[E]),inside:s.languages.csharp},"class-name":{pattern:RegExp(m),inside:{punctuation:/\./}},punctuation:/[:,]/}}});var z=":[^}\r\n]+",S=e(a("[^\"'/()]|<<0>>|\\(<>*\\)",[B]),2),j=a("\\{(?!\\{)(?:(?![}:])<<0>>)*<<1>>?\\}",[S,z]),A=e(a("[^\"'/()]|/(?!\\*)|/\\*(?:[^*]|\\*(?!/))*\\*/|<<0>>|\\(<>*\\)",[_]),2),F=a("\\{(?!\\{)(?:(?![}:])<<0>>)*<<1>>?\\}",[A,z]);function U(e,n){return{interpolation:{pattern:t("((?:^|[^{])(?:\\{\\{)*)<<0>>",[e]),lookbehind:!0,inside:{"format-string":{pattern:t("(^\\{(?:(?![}:])<<0>>)*)<<1>>(?=\\}$)",[n,z]),lookbehind:!0,inside:{punctuation:/^:/}},punctuation:/^\{|\}$/,expression:{pattern:/[\s\S]+/,alias:"language-csharp",inside:s.languages.csharp}}},string:/[\s\S]+/}}s.languages.insertBefore("csharp","string",{"interpolation-string":[{pattern:t('(^|[^\\\\])(?:\\$@|@\\$)"(?:""|\\\\[^]|\\{\\{|<<0>>|[^\\\\{"])*"',[j]),lookbehind:!0,greedy:!0,inside:U(j,S)},{pattern:t('(^|[^@\\\\])\\$"(?:\\\\.|\\{\\{|<<0>>|[^\\\\"{])*"',[F]),lookbehind:!0,greedy:!0,inside:U(F,A)}]})}(Prism),Prism.languages.dotnet=Prism.languages.cs=Prism.languages.csharp; Prism.languages.fsharp=Prism.languages.extend("clike",{comment:[{pattern:/(^|[^\\])\(\*[\s\S]*?\*\)/,lookbehind:!0},{pattern:/(^|[^\\:])\/\/.*/,lookbehind:!0}],string:{pattern:/(?:"""[\s\S]*?"""|@"(?:""|[^"])*"|"(?:\\[\s\S]|[^\\"])*")B?|'(?:[^\\']|\\(?:.|\d{3}|x[a-fA-F\d]{2}|u[a-fA-F\d]{4}|U[a-fA-F\d]{8}))'B?/,greedy:!0},"class-name":{pattern:/(\b(?:exception|inherit|interface|new|of|type)\s+|\w\s*:\s*|\s:\??>\s*)[.\w]+\b(?:\s*(?:->|\*)\s*[.\w]+\b)*(?!\s*[:.])/,lookbehind:!0,inside:{operator:/->|\*/,punctuation:/\./}},keyword:/\b(?:let|return|use|yield)(?:!\B|\b)|\b(?:abstract|and|as|assert|base|begin|class|default|delegate|do|done|downcast|downto|elif|else|end|exception|extern|false|finally|for|fun|function|global|if|in|inherit|inline|interface|internal|lazy|match|member|module|mutable|namespace|new|not|null|of|open|or|override|private|public|rec|select|static|struct|then|to|true|try|type|upcast|val|void|when|while|with|asr|land|lor|lsl|lsr|lxor|mod|sig|atomic|break|checked|component|const|constraint|constructor|continue|eager|event|external|fixed|functor|include|method|mixin|object|parallel|process|protected|pure|sealed|tailcall|trait|virtual|volatile)\b/,number:[/\b0x[\da-fA-F]+(?:un|lf|LF)?\b/,/\b0b[01]+(?:y|uy)?\b/,/(?:\b\d+\.?\d*|\B\.\d+)(?:[fm]|e[+-]?\d+)?\b/i,/\b\d+(?:[IlLsy]|u[lsy]?|UL)?\b/],operator:/([<>~&^])\1\1|([*.:<>&])\2|<-|->|[!=:]=|?|\??(?:<=|>=|<>|[-+*/%=<>])\??|[!?^&]|~[+~-]|:>|:\?>?/}),Prism.languages.insertBefore("fsharp","keyword",{preprocessor:{pattern:/^[^\r\n\S]*#.*/m,alias:"property",inside:{directive:{pattern:/(\s*#)\b(?:else|endif|if|light|line|nowarn)\b/,lookbehind:!0,alias:"keyword"}}}}),Prism.languages.insertBefore("fsharp","punctuation",{"computation-expression":{pattern:/[_a-z]\w*(?=\s*\{)/i,alias:"keyword"}}),Prism.languages.insertBefore("fsharp","string",{annotation:{pattern:/\[<.+?>\]/,inside:{punctuation:/^\[<|>\]$/,"class-name":{pattern:/^\w+$|(^|;\s*)[A-Z]\w*(?=\()/,lookbehind:!0},"annotation-content":{pattern:/[\s\S]+/,inside:Prism.languages.fsharp}}}}); !function(e){var i=Prism.languages.powershell={comment:[{pattern:/(^|[^`])<#[\s\S]*?#>/,lookbehind:!0},{pattern:/(^|[^`])#.*/,lookbehind:!0}],string:[{pattern:/"(?:`[\s\S]|[^`"])*"/,greedy:!0,inside:{function:{pattern:/(^|[^`])\$\((?:\$\([^\r\n()]*\)|(?!\$\()[^\r\n)])*\)/,lookbehind:!0,inside:{}}}},{pattern:/'(?:[^']|'')*'/,greedy:!0}],namespace:/\[[a-z](?:\[(?:\[[^\]]*]|[^\[\]])*]|[^\[\]])*]/i,boolean:/\$(?:true|false)\b/i,variable:/\$\w+\b/,function:[/\b(?:Add|Approve|Assert|Backup|Block|Checkpoint|Clear|Close|Compare|Complete|Compress|Confirm|Connect|Convert|ConvertFrom|ConvertTo|Copy|Debug|Deny|Disable|Disconnect|Dismount|Edit|Enable|Enter|Exit|Expand|Export|Find|ForEach|Format|Get|Grant|Group|Hide|Import|Initialize|Install|Invoke|Join|Limit|Lock|Measure|Merge|Move|New|Open|Optimize|Out|Ping|Pop|Protect|Publish|Push|Read|Receive|Redo|Register|Remove|Rename|Repair|Request|Reset|Resize|Resolve|Restart|Restore|Resume|Revoke|Save|Search|Select|Send|Set|Show|Skip|Sort|Split|Start|Step|Stop|Submit|Suspend|Switch|Sync|Tee|Test|Trace|Unblock|Undo|Uninstall|Unlock|Unprotect|Unpublish|Unregister|Update|Use|Wait|Watch|Where|Write)-[a-z]+\b/i,/\b(?:ac|cat|chdir|clc|cli|clp|clv|compare|copy|cp|cpi|cpp|cvpa|dbp|del|diff|dir|ebp|echo|epal|epcsv|epsn|erase|fc|fl|ft|fw|gal|gbp|gc|gci|gcs|gdr|gi|gl|gm|gp|gps|group|gsv|gu|gv|gwmi|iex|ii|ipal|ipcsv|ipsn|irm|iwmi|iwr|kill|lp|ls|measure|mi|mount|move|mp|mv|nal|ndr|ni|nv|ogv|popd|ps|pushd|pwd|rbp|rd|rdr|ren|ri|rm|rmdir|rni|rnp|rp|rv|rvpa|rwmi|sal|saps|sasv|sbp|sc|select|set|shcm|si|sl|sleep|sls|sort|sp|spps|spsv|start|sv|swmi|tee|trcm|type|write)\b/i],keyword:/\b(?:Begin|Break|Catch|Class|Continue|Data|Define|Do|DynamicParam|Else|ElseIf|End|Exit|Filter|Finally|For|ForEach|From|Function|If|InlineScript|Parallel|Param|Process|Return|Sequence|Switch|Throw|Trap|Try|Until|Using|Var|While|Workflow)\b/i,operator:{pattern:/(\W?)(?:!|-(?:eq|ne|gt|ge|lt|le|sh[lr]|not|b?(?:and|x?or)|(?:Not)?(?:Like|Match|Contains|In)|Replace|Join|is(?:Not)?|as)\b|-[-=]?|\+[+=]?|[*\/%]=?)/i,lookbehind:!0},punctuation:/[|{}[\];(),.]/},r=i.string[0].inside;r.boolean=i.boolean,r.variable=i.variable,r.function.inside=i}(); Prism.languages.sql={comment:{pattern:/(^|[^\\])(?:\/\*[\s\S]*?\*\/|(?:--|\/\/|#).*)/,lookbehind:!0},variable:[{pattern:/@(["'`])(?:\\[\s\S]|(?!\1)[^\\])+\1/,greedy:!0},/@[\w.$]+/],string:{pattern:/(^|[^@\\])("|')(?:\\[\s\S]|(?!\2)[^\\]|\2\2)*\2/,greedy:!0,lookbehind:!0},function:/\b(?:AVG|COUNT|FIRST|FORMAT|LAST|LCASE|LEN|MAX|MID|MIN|MOD|NOW|ROUND|SUM|UCASE)(?=\s*\()/i,keyword:/\b(?:ACTION|ADD|AFTER|ALGORITHM|ALL|ALTER|ANALYZE|ANY|APPLY|AS|ASC|AUTHORIZATION|AUTO_INCREMENT|BACKUP|BDB|BEGIN|BERKELEYDB|BIGINT|BINARY|BIT|BLOB|BOOL|BOOLEAN|BREAK|BROWSE|BTREE|BULK|BY|CALL|CASCADED?|CASE|CHAIN|CHAR(?:ACTER|SET)?|CHECK(?:POINT)?|CLOSE|CLUSTERED|COALESCE|COLLATE|COLUMNS?|COMMENT|COMMIT(?:TED)?|COMPUTE|CONNECT|CONSISTENT|CONSTRAINT|CONTAINS(?:TABLE)?|CONTINUE|CONVERT|CREATE|CROSS|CURRENT(?:_DATE|_TIME|_TIMESTAMP|_USER)?|CURSOR|CYCLE|DATA(?:BASES?)?|DATE(?:TIME)?|DAY|DBCC|DEALLOCATE|DEC|DECIMAL|DECLARE|DEFAULT|DEFINER|DELAYED|DELETE|DELIMITERS?|DENY|DESC|DESCRIBE|DETERMINISTIC|DISABLE|DISCARD|DISK|DISTINCT|DISTINCTROW|DISTRIBUTED|DO|DOUBLE|DROP|DUMMY|DUMP(?:FILE)?|DUPLICATE|ELSE(?:IF)?|ENABLE|ENCLOSED|END|ENGINE|ENUM|ERRLVL|ERRORS|ESCAPED?|EXCEPT|EXEC(?:UTE)?|EXISTS|EXIT|EXPLAIN|EXTENDED|FETCH|FIELDS|FILE|FILLFACTOR|FIRST|FIXED|FLOAT|FOLLOWING|FOR(?: EACH ROW)?|FORCE|FOREIGN|FREETEXT(?:TABLE)?|FROM|FULL|FUNCTION|GEOMETRY(?:COLLECTION)?|GLOBAL|GOTO|GRANT|GROUP|HANDLER|HASH|HAVING|HOLDLOCK|HOUR|IDENTITY(?:_INSERT|COL)?|IF|IGNORE|IMPORT|INDEX|INFILE|INNER|INNODB|INOUT|INSERT|INT|INTEGER|INTERSECT|INTERVAL|INTO|INVOKER|ISOLATION|ITERATE|JOIN|KEYS?|KILL|LANGUAGE|LAST|LEAVE|LEFT|LEVEL|LIMIT|LINENO|LINES|LINESTRING|LOAD|LOCAL|LOCK|LONG(?:BLOB|TEXT)|LOOP|MATCH(?:ED)?|MEDIUM(?:BLOB|INT|TEXT)|MERGE|MIDDLEINT|MINUTE|MODE|MODIFIES|MODIFY|MONTH|MULTI(?:LINESTRING|POINT|POLYGON)|NATIONAL|NATURAL|NCHAR|NEXT|NO|NONCLUSTERED|NULLIF|NUMERIC|OFF?|OFFSETS?|ON|OPEN(?:DATASOURCE|QUERY|ROWSET)?|OPTIMIZE|OPTION(?:ALLY)?|ORDER|OUT(?:ER|FILE)?|OVER|PARTIAL|PARTITION|PERCENT|PIVOT|PLAN|POINT|POLYGON|PRECEDING|PRECISION|PREPARE|PREV|PRIMARY|PRINT|PRIVILEGES|PROC(?:EDURE)?|PUBLIC|PURGE|QUICK|RAISERROR|READS?|REAL|RECONFIGURE|REFERENCES|RELEASE|RENAME|REPEAT(?:ABLE)?|REPLACE|REPLICATION|REQUIRE|RESIGNAL|RESTORE|RESTRICT|RETURN(?:S|ING)?|REVOKE|RIGHT|ROLLBACK|ROUTINE|ROW(?:COUNT|GUIDCOL|S)?|RTREE|RULE|SAVE(?:POINT)?|SCHEMA|SECOND|SELECT|SERIAL(?:IZABLE)?|SESSION(?:_USER)?|SET(?:USER)?|SHARE|SHOW|SHUTDOWN|SIMPLE|SMALLINT|SNAPSHOT|SOME|SONAME|SQL|START(?:ING)?|STATISTICS|STATUS|STRIPED|SYSTEM_USER|TABLES?|TABLESPACE|TEMP(?:ORARY|TABLE)?|TERMINATED|TEXT(?:SIZE)?|THEN|TIME(?:STAMP)?|TINY(?:BLOB|INT|TEXT)|TOP?|TRAN(?:SACTIONS?)?|TRIGGER|TRUNCATE|TSEQUAL|TYPES?|UNBOUNDED|UNCOMMITTED|UNDEFINED|UNION|UNIQUE|UNLOCK|UNPIVOT|UNSIGNED|UPDATE(?:TEXT)?|USAGE|USE|USER|USING|VALUES?|VAR(?:BINARY|CHAR|CHARACTER|YING)|VIEW|WAITFOR|WARNINGS|WHEN|WHERE|WHILE|WITH(?: ROLLUP|IN)?|WORK|WRITE(?:TEXT)?|YEAR)\b/i,boolean:/\b(?:TRUE|FALSE|NULL)\b/i,number:/\b0x[\da-f]+\b|\b\d+\.?\d*|\B\.\d+\b/i,operator:/[-+*\/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?|\b(?:AND|BETWEEN|IN|LIKE|NOT|OR|IS|DIV|REGEXP|RLIKE|SOUNDS LIKE|XOR)\b/i,punctuation:/[;[\]()`,.]/}; !function(){if("undefined"!=typeof self&&self.Prism&&self.document){var i=[],l={},c=function(){};Prism.plugins.toolbar={};var e=Prism.plugins.toolbar.registerButton=function(e,n){var t;t="function"==typeof n?n:function(e){var t;return"function"==typeof n.onClick?((t=document.createElement("button")).type="button",t.addEventListener("click",function(){n.onClick.call(this,e)})):"string"==typeof n.url?(t=document.createElement("a")).href=n.url:t=document.createElement("span"),n.className&&t.classList.add(n.className),t.textContent=n.text,t},e in l?console.warn('There is a button with the key "'+e+'" registered already.'):i.push(l[e]=t)},t=Prism.plugins.toolbar.hook=function(a){var e=a.element.parentNode;if(e&&/pre/i.test(e.nodeName)&&!e.parentNode.classList.contains("code-toolbar")){var t=document.createElement("div");t.classList.add("code-toolbar"),e.parentNode.insertBefore(t,e),t.appendChild(e);var r=document.createElement("div");r.classList.add("toolbar");var n=i,o=function(e){for(;e;){var t=e.getAttribute("data-toolbar-order");if(null!=t)return(t=t.trim()).length?t.split(/\s*,\s*/g):[];e=e.parentElement}}(a.element);o&&(n=o.map(function(e){return l[e]||c})),n.forEach(function(e){var t=e(a);if(t){var n=document.createElement("div");n.classList.add("toolbar-item"),n.appendChild(t),r.appendChild(n)}}),t.appendChild(r)}};e("label",function(e){var t=e.element.parentNode;if(t&&/pre/i.test(t.nodeName)&&t.hasAttribute("data-label")){var n,a,r=t.getAttribute("data-label");try{a=document.querySelector("template#"+r)}catch(e){}return a?n=a.content:(t.hasAttribute("data-url")?(n=document.createElement("a")).href=t.getAttribute("data-url"):n=document.createElement("span"),n.textContent=r),n}}),Prism.hooks.add("complete",t)}}(); ================================================ FILE: docs/style.css ================================================ :root { --gray: rgba(51,51,51,.6); --silver: rgb(204, 204, 204); --slate: rgb(51, 51, 51); --merlot: rgb(59, 3, 109); --purple: rgb(120, 60, 180); --washed-purple: rgba(120, 60, 180, 0.17); } /* sizing */ .h100vh { min-height: 100vh } .mw9 { max-width: 80rem } /* font */ .noto { font-family: 'Noto Sans JP', sans-serif } .noto-serif { font-family: 'Noto Serif JP', Georgia, serif } /* bg */ .bg-merlot { background-color: var(--merlot) } .bg-dots { background-image: radial-gradient(rgba(255,255,255,.3) 1px, transparent 1px), radial-gradient(rgba(255,255,255,.3) 1px, transparent 1px); background-size: 75px 75px; background-position: 0px 0px,40px 25px } .bg-parallax { background-attachment: fixed } /* color */ .merlot { color: var(--merlot) } /* opacity */ .hover-o-100:hover { opacity: 1 } /* transform */ .ty--50 { transform: translateY(-50%);} /* text */ main h1, main h2, main h3, main h4, main h5 { margin-bottom: 0; font-weight: 400 } main h1 { margin-top: 0; font-size: 2.5rem } main h2 { margin-top: 3rem; font-size: 2em; color: var(--gray) } main h3 { margin-top: 2rem; font-size: 1.5em } main h2 + h3 { margin-top: .5rem } main p { line-height: 1.4 } a { color: inherit; transition: color .125s ease-in } main a:visited, .sidebar a:visited { color: inherit } main a:hover, .sidebar a:hover { color: var(--merlot) } blockquote { margin-left: 0; margin-right: 0; padding: 1rem; border-left: 4px solid var(--purple); background: var(--washed-purple) } blockquote p { margin: 0 } /* tables */ table { text-align: left; border-collapse: collapse } td, th { padding: .5rem; border-bottom: 1px solid var(--silver) } /* code */ code[class*="language-"], pre[class*="language-"] { font-size: .75rem } pre[class*="language-"] { padding: 1rem } pre { overflow-x: auto; overflow-wrap: normal; white-space: pre; font-size: .875rem; margin: 1rem 0; padding: 1rem; color: #eff0f9; background: #333 } code { vertical-align: middle; font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; background-color: var(--washed-purple) } pre > code { background-color: inherit } h2 > code, h3 > code, p > code { padding: 0 .25rem } @media screen and (min-width: 60em) { code[class*="language-"], pre[class*="language-"] { font-size: .875rem } } ================================================ FILE: docs/tachyons.css ================================================ html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}.border-box,a,article,aside,blockquote,body,code,dd,div,dl,dt,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,header,html,input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=url],legend,li,main,nav,ol,p,pre,section,table,td,textarea,th,tr,ul{box-sizing:border-box}.aspect-ratio{height:0;position:relative}.aspect-ratio--16x9{padding-bottom:56.25%}.aspect-ratio--9x16{padding-bottom:177.77%}.aspect-ratio--4x3{padding-bottom:75%}.aspect-ratio--3x4{padding-bottom:133.33%}.aspect-ratio--6x4{padding-bottom:66.6%}.aspect-ratio--4x6{padding-bottom:150%}.aspect-ratio--8x5{padding-bottom:62.5%}.aspect-ratio--5x8{padding-bottom:160%}.aspect-ratio--7x5{padding-bottom:71.42%}.aspect-ratio--5x7{padding-bottom:140%}.aspect-ratio--1x1{padding-bottom:100%}.aspect-ratio--object{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}img{max-width:100%}.cover{background-size:cover!important}.contain{background-size:contain!important}.bg-center{background-position:50%}.bg-center,.bg-top{background-repeat:no-repeat}.bg-top{background-position:top}.bg-right{background-position:100%}.bg-bottom,.bg-right{background-repeat:no-repeat}.bg-bottom{background-position:bottom}.bg-left{background-repeat:no-repeat;background-position:0}.outline{outline:1px solid}.outline-transparent{outline:1px solid transparent}.outline-0{outline:0}.ba{border-style:solid;border-width:1px}.bt{border-top-style:solid;border-top-width:1px}.br{border-right-style:solid;border-right-width:1px}.bb{border-bottom-style:solid;border-bottom-width:1px}.bl{border-left-style:solid;border-left-width:1px}.bn{border-style:none;border-width:0}.b--black{border-color:#000}.b--near-black{border-color:#111}.b--dark-gray{border-color:#333}.b--mid-gray{border-color:#555}.b--gray{border-color:#777}.b--silver{border-color:#999}.b--light-silver{border-color:#aaa}.b--moon-gray{border-color:#ccc}.b--light-gray{border-color:#eee}.b--near-white{border-color:#f4f4f4}.b--white{border-color:#fff}.b--white-90{border-color:hsla(0,0%,100%,.9)}.b--white-80{border-color:hsla(0,0%,100%,.8)}.b--white-70{border-color:hsla(0,0%,100%,.7)}.b--white-60{border-color:hsla(0,0%,100%,.6)}.b--white-50{border-color:hsla(0,0%,100%,.5)}.b--white-40{border-color:hsla(0,0%,100%,.4)}.b--white-30{border-color:hsla(0,0%,100%,.3)}.b--white-20{border-color:hsla(0,0%,100%,.2)}.b--white-10{border-color:hsla(0,0%,100%,.1)}.b--white-05{border-color:hsla(0,0%,100%,.05)}.b--white-025{border-color:hsla(0,0%,100%,.025)}.b--white-0125{border-color:hsla(0,0%,100%,.0125)}.b--black-90{border-color:rgba(0,0,0,.9)}.b--black-80{border-color:rgba(0,0,0,.8)}.b--black-70{border-color:rgba(0,0,0,.7)}.b--black-60{border-color:rgba(0,0,0,.6)}.b--black-50{border-color:rgba(0,0,0,.5)}.b--black-40{border-color:rgba(0,0,0,.4)}.b--black-30{border-color:rgba(0,0,0,.3)}.b--black-20{border-color:rgba(0,0,0,.2)}.b--black-10{border-color:rgba(0,0,0,.1)}.b--black-05{border-color:rgba(0,0,0,.05)}.b--black-025{border-color:rgba(0,0,0,.025)}.b--black-0125{border-color:rgba(0,0,0,.0125)}.b--dark-red{border-color:#e7040f}.b--red{border-color:#ff4136}.b--light-red{border-color:#ff725c}.b--orange{border-color:#ff6300}.b--gold{border-color:#ffb700}.b--yellow{border-color:gold}.b--light-yellow{border-color:#fbf1a9}.b--purple{border-color:#5e2ca5}.b--light-purple{border-color:#a463f2}.b--dark-pink{border-color:#d5008f}.b--hot-pink{border-color:#ff41b4}.b--pink{border-color:#ff80cc}.b--light-pink{border-color:#ffa3d7}.b--dark-green{border-color:#137752}.b--green{border-color:#19a974}.b--light-green{border-color:#9eebcf}.b--navy{border-color:#001b44}.b--dark-blue{border-color:#00449e}.b--blue{border-color:#357edd}.b--light-blue{border-color:#96ccff}.b--lightest-blue{border-color:#cdecff}.b--washed-blue{border-color:#f6fffe}.b--washed-green{border-color:#e8fdf5}.b--washed-yellow{border-color:#fffceb}.b--washed-red{border-color:#ffdfdf}.b--transparent{border-color:transparent}.b--inherit{border-color:inherit}.b--initial{border-color:initial}.b--unset{border-color:unset}.br0{border-radius:0}.br1{border-radius:.125rem}.br2{border-radius:.25rem}.br3{border-radius:.5rem}.br4{border-radius:1rem}.br-100{border-radius:100%}.br-pill{border-radius:9999px}.br--bottom{border-top-left-radius:0;border-top-right-radius:0}.br--top{border-bottom-right-radius:0}.br--right,.br--top{border-bottom-left-radius:0}.br--right{border-top-left-radius:0}.br--left{border-top-right-radius:0;border-bottom-right-radius:0}.br-inherit{border-radius:inherit}.br-initial{border-radius:initial}.br-unset{border-radius:unset}.b--dotted{border-style:dotted}.b--dashed{border-style:dashed}.b--solid{border-style:solid}.b--none{border-style:none}.bw0{border-width:0}.bw1{border-width:.125rem}.bw2{border-width:.25rem}.bw3{border-width:.5rem}.bw4{border-width:1rem}.bw5{border-width:2rem}.bt-0{border-top-width:0}.br-0{border-right-width:0}.bb-0{border-bottom-width:0}.bl-0{border-left-width:0}.shadow-1{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.pre{overflow-x:auto;overflow-y:hidden;overflow:scroll}.top-0{top:0}.right-0{right:0}.bottom-0{bottom:0}.left-0{left:0}.top-1{top:1rem}.right-1{right:1rem}.bottom-1{bottom:1rem}.left-1{left:1rem}.top-2{top:2rem}.right-2{right:2rem}.bottom-2{bottom:2rem}.left-2{left:2rem}.top--1{top:-1rem}.right--1{right:-1rem}.bottom--1{bottom:-1rem}.left--1{left:-1rem}.top--2{top:-2rem}.right--2{right:-2rem}.bottom--2{bottom:-2rem}.left--2{left:-2rem}.absolute--fill{top:0;right:0;bottom:0;left:0}.cf:after,.cf:before{content:" ";display:table}.cf:after{clear:both}.cf{*zoom:1}.cl{clear:left}.cr{clear:right}.cb{clear:both}.cn{clear:none}.dn{display:none}.di{display:inline}.db{display:block}.dib{display:inline-block}.dit{display:inline-table}.dt{display:table}.dtc{display:table-cell}.dt-row{display:table-row}.dt-row-group{display:table-row-group}.dt-column{display:table-column}.dt-column-group{display:table-column-group}.dt--fixed{table-layout:fixed;width:100%}.flex{display:flex}.inline-flex{display:inline-flex}.flex-auto{flex:1 1 auto;min-width:0;min-height:0}.flex-none{flex:none}.flex-column{flex-direction:column}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.flex-nowrap{flex-wrap:nowrap}.flex-wrap-reverse{flex-wrap:wrap-reverse}.flex-column-reverse{flex-direction:column-reverse}.flex-row-reverse{flex-direction:row-reverse}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.self-start{align-self:flex-start}.self-end{align-self:flex-end}.self-center{align-self:center}.self-baseline{align-self:baseline}.self-stretch{align-self:stretch}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-around{justify-content:space-around}.content-start{align-content:flex-start}.content-end{align-content:flex-end}.content-center{align-content:center}.content-between{align-content:space-between}.content-around{align-content:space-around}.content-stretch{align-content:stretch}.order-0{order:0}.order-1{order:1}.order-2{order:2}.order-3{order:3}.order-4{order:4}.order-5{order:5}.order-6{order:6}.order-7{order:7}.order-8{order:8}.order-last{order:99999}.flex-grow-0{flex-grow:0}.flex-grow-1{flex-grow:1}.flex-shrink-0{flex-shrink:0}.flex-shrink-1{flex-shrink:1}.fl{float:left}.fl,.fr{_display:inline}.fr{float:right}.fn{float:none}.sans-serif{font-family:-apple-system,BlinkMacSystemFont,avenir next,avenir,helvetica neue,helvetica,ubuntu,roboto,noto,segoe ui,arial,sans-serif}.serif{font-family:georgia,times,serif}.system-sans-serif{font-family:sans-serif}.system-serif{font-family:serif}.code,code{font-family:Consolas,monaco,monospace}.courier{font-family:Courier Next,courier,monospace}.helvetica{font-family:helvetica neue,helvetica,sans-serif}.avenir{font-family:avenir next,avenir,sans-serif}.athelas{font-family:athelas,georgia,serif}.georgia{font-family:georgia,serif}.times{font-family:times,serif}.bodoni{font-family:Bodoni MT,serif}.calisto{font-family:Calisto MT,serif}.garamond{font-family:garamond,serif}.baskerville{font-family:baskerville,serif}.i{font-style:italic}.fs-normal{font-style:normal}.normal{font-weight:400}.b{font-weight:700}.fw1{font-weight:100}.fw2{font-weight:200}.fw3{font-weight:300}.fw4{font-weight:400}.fw5{font-weight:500}.fw6{font-weight:600}.fw7{font-weight:700}.fw8{font-weight:800}.fw9{font-weight:900}.input-reset{-webkit-appearance:none;-moz-appearance:none}.button-reset::-moz-focus-inner,.input-reset::-moz-focus-inner{border:0;padding:0}.h1{height:1rem}.h2{height:2rem}.h3{height:4rem}.h4{height:8rem}.h5{height:16rem}.h-25{height:25%}.h-50{height:50%}.h-75{height:75%}.h-100{height:100%}.min-h-100{min-height:100%}.vh-25{height:25vh}.vh-50{height:50vh}.vh-75{height:75vh}.vh-100{height:100vh}.min-vh-100{min-height:100vh}.h-auto{height:auto}.h-inherit{height:inherit}.tracked{letter-spacing:.1em}.tracked-tight{letter-spacing:-.05em}.tracked-mega{letter-spacing:.25em}.lh-solid{line-height:1}.lh-title{line-height:1.25}.lh-copy{line-height:1.5}.link{text-decoration:none}.link,.link:active,.link:focus,.link:hover,.link:link,.link:visited{transition:color .15s ease-in}.link:focus{outline:1px dotted currentColor}.list{list-style-type:none}.mw-100{max-width:100%}.mw1{max-width:1rem}.mw2{max-width:2rem}.mw3{max-width:4rem}.mw4{max-width:8rem}.mw5{max-width:16rem}.mw6{max-width:32rem}.mw7{max-width:48rem}.mw8{max-width:64rem}.mw9{max-width:96rem}.mw-none{max-width:none}.w1{width:1rem}.w2{width:2rem}.w3{width:4rem}.w4{width:8rem}.w5{width:16rem}.w-10{width:10%}.w-20{width:20%}.w-25{width:25%}.w-30{width:30%}.w-33{width:33%}.w-34{width:34%}.w-40{width:40%}.w-50{width:50%}.w-60{width:60%}.w-70{width:70%}.w-75{width:75%}.w-80{width:80%}.w-90{width:90%}.w-100{width:100%}.w-third{width:33.33333%}.w-two-thirds{width:66.66667%}.w-auto{width:auto}.overflow-visible{overflow:visible}.overflow-hidden{overflow:hidden}.overflow-scroll{overflow:scroll}.overflow-auto{overflow:auto}.overflow-x-visible{overflow-x:visible}.overflow-x-hidden{overflow-x:hidden}.overflow-x-scroll{overflow-x:scroll}.overflow-x-auto{overflow-x:auto}.overflow-y-visible{overflow-y:visible}.overflow-y-hidden{overflow-y:hidden}.overflow-y-scroll{overflow-y:scroll}.overflow-y-auto{overflow-y:auto}.static{position:static}.relative{position:relative}.absolute{position:absolute}.fixed{position:fixed}.o-100{opacity:1}.o-90{opacity:.9}.o-80{opacity:.8}.o-70{opacity:.7}.o-60{opacity:.6}.o-50{opacity:.5}.o-40{opacity:.4}.o-30{opacity:.3}.o-20{opacity:.2}.o-10{opacity:.1}.o-05{opacity:.05}.o-025{opacity:.025}.o-0{opacity:0}.rotate-45{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.black-90{color:rgba(0,0,0,.9)}.black-80{color:rgba(0,0,0,.8)}.black-70{color:rgba(0,0,0,.7)}.black-60{color:rgba(0,0,0,.6)}.black-50{color:rgba(0,0,0,.5)}.black-40{color:rgba(0,0,0,.4)}.black-30{color:rgba(0,0,0,.3)}.black-20{color:rgba(0,0,0,.2)}.black-10{color:rgba(0,0,0,.1)}.black-05{color:rgba(0,0,0,.05)}.white-90{color:hsla(0,0%,100%,.9)}.white-80{color:hsla(0,0%,100%,.8)}.white-70{color:hsla(0,0%,100%,.7)}.white-60{color:hsla(0,0%,100%,.6)}.white-50{color:hsla(0,0%,100%,.5)}.white-40{color:hsla(0,0%,100%,.4)}.white-30{color:hsla(0,0%,100%,.3)}.white-20{color:hsla(0,0%,100%,.2)}.white-10{color:hsla(0,0%,100%,.1)}.black{color:#000}.near-black{color:#111}.dark-gray{color:#333}.mid-gray{color:#555}.gray{color:#777}.silver{color:#999}.light-silver{color:#aaa}.moon-gray{color:#ccc}.light-gray{color:#eee}.near-white{color:#f4f4f4}.white{color:#fff}.dark-red{color:#e7040f}.red{color:#ff4136}.light-red{color:#ff725c}.orange{color:#ff6300}.gold{color:#ffb700}.yellow{color:gold}.light-yellow{color:#fbf1a9}.purple{color:#5e2ca5}.light-purple{color:#a463f2}.dark-pink{color:#d5008f}.hot-pink{color:#ff41b4}.pink{color:#ff80cc}.light-pink{color:#ffa3d7}.dark-green{color:#137752}.green{color:#19a974}.light-green{color:#9eebcf}.navy{color:#001b44}.dark-blue{color:#00449e}.blue{color:#357edd}.light-blue{color:#96ccff}.lightest-blue{color:#cdecff}.washed-blue{color:#f6fffe}.washed-green{color:#e8fdf5}.washed-yellow{color:#fffceb}.washed-red{color:#ffdfdf}.color-inherit{color:inherit}.bg-black-90{background-color:rgba(0,0,0,.9)}.bg-black-80{background-color:rgba(0,0,0,.8)}.bg-black-70{background-color:rgba(0,0,0,.7)}.bg-black-60{background-color:rgba(0,0,0,.6)}.bg-black-50{background-color:rgba(0,0,0,.5)}.bg-black-40{background-color:rgba(0,0,0,.4)}.bg-black-30{background-color:rgba(0,0,0,.3)}.bg-black-20{background-color:rgba(0,0,0,.2)}.bg-black-10{background-color:rgba(0,0,0,.1)}.bg-black-05{background-color:rgba(0,0,0,.05)}.bg-white-90{background-color:hsla(0,0%,100%,.9)}.bg-white-80{background-color:hsla(0,0%,100%,.8)}.bg-white-70{background-color:hsla(0,0%,100%,.7)}.bg-white-60{background-color:hsla(0,0%,100%,.6)}.bg-white-50{background-color:hsla(0,0%,100%,.5)}.bg-white-40{background-color:hsla(0,0%,100%,.4)}.bg-white-30{background-color:hsla(0,0%,100%,.3)}.bg-white-20{background-color:hsla(0,0%,100%,.2)}.bg-white-10{background-color:hsla(0,0%,100%,.1)}.bg-black{background-color:#000}.bg-near-black{background-color:#111}.bg-dark-gray{background-color:#333}.bg-mid-gray{background-color:#555}.bg-gray{background-color:#777}.bg-silver{background-color:#999}.bg-light-silver{background-color:#aaa}.bg-moon-gray{background-color:#ccc}.bg-light-gray{background-color:#eee}.bg-near-white{background-color:#f4f4f4}.bg-white{background-color:#fff}.bg-transparent{background-color:transparent}.bg-dark-red{background-color:#e7040f}.bg-red{background-color:#ff4136}.bg-light-red{background-color:#ff725c}.bg-orange{background-color:#ff6300}.bg-gold{background-color:#ffb700}.bg-yellow{background-color:gold}.bg-light-yellow{background-color:#fbf1a9}.bg-purple{background-color:#5e2ca5}.bg-light-purple{background-color:#a463f2}.bg-dark-pink{background-color:#d5008f}.bg-hot-pink{background-color:#ff41b4}.bg-pink{background-color:#ff80cc}.bg-light-pink{background-color:#ffa3d7}.bg-dark-green{background-color:#137752}.bg-green{background-color:#19a974}.bg-light-green{background-color:#9eebcf}.bg-navy{background-color:#001b44}.bg-dark-blue{background-color:#00449e}.bg-blue{background-color:#357edd}.bg-light-blue{background-color:#96ccff}.bg-lightest-blue{background-color:#cdecff}.bg-washed-blue{background-color:#f6fffe}.bg-washed-green{background-color:#e8fdf5}.bg-washed-yellow{background-color:#fffceb}.bg-washed-red{background-color:#ffdfdf}.bg-inherit{background-color:inherit}.hover-black:focus,.hover-black:hover{color:#000}.hover-near-black:focus,.hover-near-black:hover{color:#111}.hover-dark-gray:focus,.hover-dark-gray:hover{color:#333}.hover-mid-gray:focus,.hover-mid-gray:hover{color:#555}.hover-gray:focus,.hover-gray:hover{color:#777}.hover-silver:focus,.hover-silver:hover{color:#999}.hover-light-silver:focus,.hover-light-silver:hover{color:#aaa}.hover-moon-gray:focus,.hover-moon-gray:hover{color:#ccc}.hover-light-gray:focus,.hover-light-gray:hover{color:#eee}.hover-near-white:focus,.hover-near-white:hover{color:#f4f4f4}.hover-white:focus,.hover-white:hover{color:#fff}.hover-black-90:focus,.hover-black-90:hover{color:rgba(0,0,0,.9)}.hover-black-80:focus,.hover-black-80:hover{color:rgba(0,0,0,.8)}.hover-black-70:focus,.hover-black-70:hover{color:rgba(0,0,0,.7)}.hover-black-60:focus,.hover-black-60:hover{color:rgba(0,0,0,.6)}.hover-black-50:focus,.hover-black-50:hover{color:rgba(0,0,0,.5)}.hover-black-40:focus,.hover-black-40:hover{color:rgba(0,0,0,.4)}.hover-black-30:focus,.hover-black-30:hover{color:rgba(0,0,0,.3)}.hover-black-20:focus,.hover-black-20:hover{color:rgba(0,0,0,.2)}.hover-black-10:focus,.hover-black-10:hover{color:rgba(0,0,0,.1)}.hover-white-90:focus,.hover-white-90:hover{color:hsla(0,0%,100%,.9)}.hover-white-80:focus,.hover-white-80:hover{color:hsla(0,0%,100%,.8)}.hover-white-70:focus,.hover-white-70:hover{color:hsla(0,0%,100%,.7)}.hover-white-60:focus,.hover-white-60:hover{color:hsla(0,0%,100%,.6)}.hover-white-50:focus,.hover-white-50:hover{color:hsla(0,0%,100%,.5)}.hover-white-40:focus,.hover-white-40:hover{color:hsla(0,0%,100%,.4)}.hover-white-30:focus,.hover-white-30:hover{color:hsla(0,0%,100%,.3)}.hover-white-20:focus,.hover-white-20:hover{color:hsla(0,0%,100%,.2)}.hover-white-10:focus,.hover-white-10:hover{color:hsla(0,0%,100%,.1)}.hover-inherit:focus,.hover-inherit:hover{color:inherit}.hover-bg-black:focus,.hover-bg-black:hover{background-color:#000}.hover-bg-near-black:focus,.hover-bg-near-black:hover{background-color:#111}.hover-bg-dark-gray:focus,.hover-bg-dark-gray:hover{background-color:#333}.hover-bg-mid-gray:focus,.hover-bg-mid-gray:hover{background-color:#555}.hover-bg-gray:focus,.hover-bg-gray:hover{background-color:#777}.hover-bg-silver:focus,.hover-bg-silver:hover{background-color:#999}.hover-bg-light-silver:focus,.hover-bg-light-silver:hover{background-color:#aaa}.hover-bg-moon-gray:focus,.hover-bg-moon-gray:hover{background-color:#ccc}.hover-bg-light-gray:focus,.hover-bg-light-gray:hover{background-color:#eee}.hover-bg-near-white:focus,.hover-bg-near-white:hover{background-color:#f4f4f4}.hover-bg-white:focus,.hover-bg-white:hover{background-color:#fff}.hover-bg-transparent:focus,.hover-bg-transparent:hover{background-color:transparent}.hover-bg-black-90:focus,.hover-bg-black-90:hover{background-color:rgba(0,0,0,.9)}.hover-bg-black-80:focus,.hover-bg-black-80:hover{background-color:rgba(0,0,0,.8)}.hover-bg-black-70:focus,.hover-bg-black-70:hover{background-color:rgba(0,0,0,.7)}.hover-bg-black-60:focus,.hover-bg-black-60:hover{background-color:rgba(0,0,0,.6)}.hover-bg-black-50:focus,.hover-bg-black-50:hover{background-color:rgba(0,0,0,.5)}.hover-bg-black-40:focus,.hover-bg-black-40:hover{background-color:rgba(0,0,0,.4)}.hover-bg-black-30:focus,.hover-bg-black-30:hover{background-color:rgba(0,0,0,.3)}.hover-bg-black-20:focus,.hover-bg-black-20:hover{background-color:rgba(0,0,0,.2)}.hover-bg-black-10:focus,.hover-bg-black-10:hover{background-color:rgba(0,0,0,.1)}.hover-bg-white-90:focus,.hover-bg-white-90:hover{background-color:hsla(0,0%,100%,.9)}.hover-bg-white-80:focus,.hover-bg-white-80:hover{background-color:hsla(0,0%,100%,.8)}.hover-bg-white-70:focus,.hover-bg-white-70:hover{background-color:hsla(0,0%,100%,.7)}.hover-bg-white-60:focus,.hover-bg-white-60:hover{background-color:hsla(0,0%,100%,.6)}.hover-bg-white-50:focus,.hover-bg-white-50:hover{background-color:hsla(0,0%,100%,.5)}.hover-bg-white-40:focus,.hover-bg-white-40:hover{background-color:hsla(0,0%,100%,.4)}.hover-bg-white-30:focus,.hover-bg-white-30:hover{background-color:hsla(0,0%,100%,.3)}.hover-bg-white-20:focus,.hover-bg-white-20:hover{background-color:hsla(0,0%,100%,.2)}.hover-bg-white-10:focus,.hover-bg-white-10:hover{background-color:hsla(0,0%,100%,.1)}.hover-dark-red:focus,.hover-dark-red:hover{color:#e7040f}.hover-red:focus,.hover-red:hover{color:#ff4136}.hover-light-red:focus,.hover-light-red:hover{color:#ff725c}.hover-orange:focus,.hover-orange:hover{color:#ff6300}.hover-gold:focus,.hover-gold:hover{color:#ffb700}.hover-yellow:focus,.hover-yellow:hover{color:gold}.hover-light-yellow:focus,.hover-light-yellow:hover{color:#fbf1a9}.hover-purple:focus,.hover-purple:hover{color:#5e2ca5}.hover-light-purple:focus,.hover-light-purple:hover{color:#a463f2}.hover-dark-pink:focus,.hover-dark-pink:hover{color:#d5008f}.hover-hot-pink:focus,.hover-hot-pink:hover{color:#ff41b4}.hover-pink:focus,.hover-pink:hover{color:#ff80cc}.hover-light-pink:focus,.hover-light-pink:hover{color:#ffa3d7}.hover-dark-green:focus,.hover-dark-green:hover{color:#137752}.hover-green:focus,.hover-green:hover{color:#19a974}.hover-light-green:focus,.hover-light-green:hover{color:#9eebcf}.hover-navy:focus,.hover-navy:hover{color:#001b44}.hover-dark-blue:focus,.hover-dark-blue:hover{color:#00449e}.hover-blue:focus,.hover-blue:hover{color:#357edd}.hover-light-blue:focus,.hover-light-blue:hover{color:#96ccff}.hover-lightest-blue:focus,.hover-lightest-blue:hover{color:#cdecff}.hover-washed-blue:focus,.hover-washed-blue:hover{color:#f6fffe}.hover-washed-green:focus,.hover-washed-green:hover{color:#e8fdf5}.hover-washed-yellow:focus,.hover-washed-yellow:hover{color:#fffceb}.hover-washed-red:focus,.hover-washed-red:hover{color:#ffdfdf}.hover-bg-dark-red:focus,.hover-bg-dark-red:hover{background-color:#e7040f}.hover-bg-red:focus,.hover-bg-red:hover{background-color:#ff4136}.hover-bg-light-red:focus,.hover-bg-light-red:hover{background-color:#ff725c}.hover-bg-orange:focus,.hover-bg-orange:hover{background-color:#ff6300}.hover-bg-gold:focus,.hover-bg-gold:hover{background-color:#ffb700}.hover-bg-yellow:focus,.hover-bg-yellow:hover{background-color:gold}.hover-bg-light-yellow:focus,.hover-bg-light-yellow:hover{background-color:#fbf1a9}.hover-bg-purple:focus,.hover-bg-purple:hover{background-color:#5e2ca5}.hover-bg-light-purple:focus,.hover-bg-light-purple:hover{background-color:#a463f2}.hover-bg-dark-pink:focus,.hover-bg-dark-pink:hover{background-color:#d5008f}.hover-bg-hot-pink:focus,.hover-bg-hot-pink:hover{background-color:#ff41b4}.hover-bg-pink:focus,.hover-bg-pink:hover{background-color:#ff80cc}.hover-bg-light-pink:focus,.hover-bg-light-pink:hover{background-color:#ffa3d7}.hover-bg-dark-green:focus,.hover-bg-dark-green:hover{background-color:#137752}.hover-bg-green:focus,.hover-bg-green:hover{background-color:#19a974}.hover-bg-light-green:focus,.hover-bg-light-green:hover{background-color:#9eebcf}.hover-bg-navy:focus,.hover-bg-navy:hover{background-color:#001b44}.hover-bg-dark-blue:focus,.hover-bg-dark-blue:hover{background-color:#00449e}.hover-bg-blue:focus,.hover-bg-blue:hover{background-color:#357edd}.hover-bg-light-blue:focus,.hover-bg-light-blue:hover{background-color:#96ccff}.hover-bg-lightest-blue:focus,.hover-bg-lightest-blue:hover{background-color:#cdecff}.hover-bg-washed-blue:focus,.hover-bg-washed-blue:hover{background-color:#f6fffe}.hover-bg-washed-green:focus,.hover-bg-washed-green:hover{background-color:#e8fdf5}.hover-bg-washed-yellow:focus,.hover-bg-washed-yellow:hover{background-color:#fffceb}.hover-bg-washed-red:focus,.hover-bg-washed-red:hover{background-color:#ffdfdf}.hover-bg-inherit:focus,.hover-bg-inherit:hover{background-color:inherit}.pa0{padding:0}.pa1{padding:.25rem}.pa2{padding:.5rem}.pa3{padding:1rem}.pa4{padding:2rem}.pa5{padding:4rem}.pa6{padding:8rem}.pa7{padding:16rem}.pl0{padding-left:0}.pl1{padding-left:.25rem}.pl2{padding-left:.5rem}.pl3{padding-left:1rem}.pl4{padding-left:2rem}.pl5{padding-left:4rem}.pl6{padding-left:8rem}.pl7{padding-left:16rem}.pr0{padding-right:0}.pr1{padding-right:.25rem}.pr2{padding-right:.5rem}.pr3{padding-right:1rem}.pr4{padding-right:2rem}.pr5{padding-right:4rem}.pr6{padding-right:8rem}.pr7{padding-right:16rem}.pb0{padding-bottom:0}.pb1{padding-bottom:.25rem}.pb2{padding-bottom:.5rem}.pb3{padding-bottom:1rem}.pb4{padding-bottom:2rem}.pb5{padding-bottom:4rem}.pb6{padding-bottom:8rem}.pb7{padding-bottom:16rem}.pt0{padding-top:0}.pt1{padding-top:.25rem}.pt2{padding-top:.5rem}.pt3{padding-top:1rem}.pt4{padding-top:2rem}.pt5{padding-top:4rem}.pt6{padding-top:8rem}.pt7{padding-top:16rem}.pv0{padding-top:0;padding-bottom:0}.pv1{padding-top:.25rem;padding-bottom:.25rem}.pv2{padding-top:.5rem;padding-bottom:.5rem}.pv3{padding-top:1rem;padding-bottom:1rem}.pv4{padding-top:2rem;padding-bottom:2rem}.pv5{padding-top:4rem;padding-bottom:4rem}.pv6{padding-top:8rem;padding-bottom:8rem}.pv7{padding-top:16rem;padding-bottom:16rem}.ph0{padding-left:0;padding-right:0}.ph1{padding-left:.25rem;padding-right:.25rem}.ph2{padding-left:.5rem;padding-right:.5rem}.ph3{padding-left:1rem;padding-right:1rem}.ph4{padding-left:2rem;padding-right:2rem}.ph5{padding-left:4rem;padding-right:4rem}.ph6{padding-left:8rem;padding-right:8rem}.ph7{padding-left:16rem;padding-right:16rem}.ma0{margin:0}.ma1{margin:.25rem}.ma2{margin:.5rem}.ma3{margin:1rem}.ma4{margin:2rem}.ma5{margin:4rem}.ma6{margin:8rem}.ma7{margin:16rem}.ml0{margin-left:0}.ml1{margin-left:.25rem}.ml2{margin-left:.5rem}.ml3{margin-left:1rem}.ml4{margin-left:2rem}.ml5{margin-left:4rem}.ml6{margin-left:8rem}.ml7{margin-left:16rem}.mr0{margin-right:0}.mr1{margin-right:.25rem}.mr2{margin-right:.5rem}.mr3{margin-right:1rem}.mr4{margin-right:2rem}.mr5{margin-right:4rem}.mr6{margin-right:8rem}.mr7{margin-right:16rem}.mb0{margin-bottom:0}.mb1{margin-bottom:.25rem}.mb2{margin-bottom:.5rem}.mb3{margin-bottom:1rem}.mb4{margin-bottom:2rem}.mb5{margin-bottom:4rem}.mb6{margin-bottom:8rem}.mb7{margin-bottom:16rem}.mt0{margin-top:0}.mt1{margin-top:.25rem}.mt2{margin-top:.5rem}.mt3{margin-top:1rem}.mt4{margin-top:2rem}.mt5{margin-top:4rem}.mt6{margin-top:8rem}.mt7{margin-top:16rem}.mv0{margin-top:0;margin-bottom:0}.mv1{margin-top:.25rem;margin-bottom:.25rem}.mv2{margin-top:.5rem;margin-bottom:.5rem}.mv3{margin-top:1rem;margin-bottom:1rem}.mv4{margin-top:2rem;margin-bottom:2rem}.mv5{margin-top:4rem;margin-bottom:4rem}.mv6{margin-top:8rem;margin-bottom:8rem}.mv7{margin-top:16rem;margin-bottom:16rem}.mh0{margin-left:0;margin-right:0}.mh1{margin-left:.25rem;margin-right:.25rem}.mh2{margin-left:.5rem;margin-right:.5rem}.mh3{margin-left:1rem;margin-right:1rem}.mh4{margin-left:2rem;margin-right:2rem}.mh5{margin-left:4rem;margin-right:4rem}.mh6{margin-left:8rem;margin-right:8rem}.mh7{margin-left:16rem;margin-right:16rem}.na1{margin:-.25rem}.na2{margin:-.5rem}.na3{margin:-1rem}.na4{margin:-2rem}.na5{margin:-4rem}.na6{margin:-8rem}.na7{margin:-16rem}.nl1{margin-left:-.25rem}.nl2{margin-left:-.5rem}.nl3{margin-left:-1rem}.nl4{margin-left:-2rem}.nl5{margin-left:-4rem}.nl6{margin-left:-8rem}.nl7{margin-left:-16rem}.nr1{margin-right:-.25rem}.nr2{margin-right:-.5rem}.nr3{margin-right:-1rem}.nr4{margin-right:-2rem}.nr5{margin-right:-4rem}.nr6{margin-right:-8rem}.nr7{margin-right:-16rem}.nb1{margin-bottom:-.25rem}.nb2{margin-bottom:-.5rem}.nb3{margin-bottom:-1rem}.nb4{margin-bottom:-2rem}.nb5{margin-bottom:-4rem}.nb6{margin-bottom:-8rem}.nb7{margin-bottom:-16rem}.nt1{margin-top:-.25rem}.nt2{margin-top:-.5rem}.nt3{margin-top:-1rem}.nt4{margin-top:-2rem}.nt5{margin-top:-4rem}.nt6{margin-top:-8rem}.nt7{margin-top:-16rem}.collapse{border-collapse:collapse;border-spacing:0}.striped--light-silver:nth-child(odd){background-color:#aaa}.striped--moon-gray:nth-child(odd){background-color:#ccc}.striped--light-gray:nth-child(odd){background-color:#eee}.striped--near-white:nth-child(odd){background-color:#f4f4f4}.stripe-light:nth-child(odd){background-color:hsla(0,0%,100%,.1)}.stripe-dark:nth-child(odd){background-color:rgba(0,0,0,.1)}.strike{text-decoration:line-through}.underline{text-decoration:underline}.no-underline{text-decoration:none}.tl{text-align:left}.tr{text-align:right}.tc{text-align:center}.tj{text-align:justify}.ttc{text-transform:capitalize}.ttl{text-transform:lowercase}.ttu{text-transform:uppercase}.ttn{text-transform:none}.f-6,.f-headline{font-size:6rem}.f-5,.f-subheadline{font-size:5rem}.f1{font-size:3rem}.f2{font-size:2.25rem}.f3{font-size:1.5rem}.f4{font-size:1.25rem}.f5{font-size:1rem}.f6{font-size:.875rem}.f7{font-size:.75rem}.measure{max-width:30em}.measure-wide{max-width:34em}.measure-narrow{max-width:20em}.indent{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps{font-variant:small-caps}.truncate{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.overflow-container{overflow-y:scroll}.center{margin-left:auto}.center,.mr-auto{margin-right:auto}.ml-auto{margin-left:auto}.clip{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal{white-space:normal}.nowrap{white-space:nowrap}.pre{white-space:pre}.v-base{vertical-align:baseline}.v-mid{vertical-align:middle}.v-top{vertical-align:top}.v-btm{vertical-align:bottom}.dim{opacity:1}.dim,.dim:focus,.dim:hover{transition:opacity .15s ease-in}.dim:focus,.dim:hover{opacity:.5}.dim:active{opacity:.8;transition:opacity .15s ease-out}.glow,.glow:focus,.glow:hover{transition:opacity .15s ease-in}.glow:focus,.glow:hover{opacity:1}.hide-child .child{opacity:0;transition:opacity .15s ease-in}.hide-child:active .child,.hide-child:focus .child,.hide-child:hover .child{opacity:1;transition:opacity .15s ease-in}.underline-hover:focus,.underline-hover:hover{text-decoration:underline}.grow{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-out;transition:transform .25s ease-out;transition:transform .25s ease-out,-webkit-transform .25s ease-out}.grow:focus,.grow:hover{-webkit-transform:scale(1.05);transform:scale(1.05)}.grow:active{-webkit-transform:scale(.9);transform:scale(.9)}.grow-large{-moz-osx-font-smoothing:grayscale;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-transform:translateZ(0);transform:translateZ(0);transition:-webkit-transform .25s ease-in-out;transition:transform .25s ease-in-out;transition:transform .25s ease-in-out,-webkit-transform .25s ease-in-out}.grow-large:focus,.grow-large:hover{-webkit-transform:scale(1.2);transform:scale(1.2)}.grow-large:active{-webkit-transform:scale(.95);transform:scale(.95)}.pointer:hover,.shadow-hover{cursor:pointer}.shadow-hover{position:relative;transition:all .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:after{content:"";box-shadow:0 0 16px 2px rgba(0,0,0,.2);border-radius:inherit;opacity:0;position:absolute;top:0;left:0;width:100%;height:100%;z-index:-1;transition:opacity .5s cubic-bezier(.165,.84,.44,1)}.shadow-hover:focus:after,.shadow-hover:hover:after{opacity:1}.bg-animate,.bg-animate:focus,.bg-animate:hover{transition:background-color .15s ease-in-out}.z-0{z-index:0}.z-1{z-index:1}.z-2{z-index:2}.z-3{z-index:3}.z-4{z-index:4}.z-5{z-index:5}.z-999{z-index:999}.z-9999{z-index:9999}.z-max{z-index:2147483647}.z-inherit{z-index:inherit}.z-initial{z-index:auto}.z-unset{z-index:unset}.nested-copy-line-height ol,.nested-copy-line-height p,.nested-copy-line-height ul{line-height:1.5}.nested-headline-line-height h1,.nested-headline-line-height h2,.nested-headline-line-height h3,.nested-headline-line-height h4,.nested-headline-line-height h5,.nested-headline-line-height h6{line-height:1.25}.nested-list-reset ol,.nested-list-reset ul{padding-left:0;margin-left:0;list-style-type:none}.nested-copy-indent p+p{text-indent:1em;margin-top:0;margin-bottom:0}.nested-copy-separator p+p{margin-top:1.5em}.nested-img img{width:100%;max-width:100%;display:block}.nested-links a{color:#357edd;transition:color .15s ease-in}.nested-links a:focus,.nested-links a:hover{color:#96ccff;transition:color .15s ease-in}.debug *{outline:1px solid gold}.debug-white *{outline:1px solid #fff}.debug-black *{outline:1px solid #000}.debug-grid{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAFElEQVR4AWPAC97/9x0eCsAEPgwAVLshdpENIxcAAAAASUVORK5CYII=) repeat 0 0}.debug-grid-16{background:transparent url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMklEQVR4AWOgCLz/b0epAa6UGuBOqQHOQHLUgFEDnAbcBZ4UGwDOkiCnkIhdgNgNxAYAiYlD+8sEuo8AAAAASUVORK5CYII=) repeat 0 0}.debug-grid-8-solid{background:#fff url(data:image/gif;base64,R0lGODdhCAAIAPEAAADw/wDx/////wAAACwAAAAACAAIAAACDZQvgaeb/lxbAIKA8y0AOw==) repeat 0 0}.debug-grid-16-solid{background:#fff url(data:image/gif;base64,R0lGODdhEAAQAPEAAADw/wDx/xXy/////ywAAAAAEAAQAAACIZyPKckYDQFsb6ZqD85jZ2+BkwiRFKehhqQCQgDHcgwEBQA7) repeat 0 0}@media screen and (min-width:30em){.aspect-ratio-ns{height:0;position:relative}.aspect-ratio--16x9-ns{padding-bottom:56.25%}.aspect-ratio--9x16-ns{padding-bottom:177.77%}.aspect-ratio--4x3-ns{padding-bottom:75%}.aspect-ratio--3x4-ns{padding-bottom:133.33%}.aspect-ratio--6x4-ns{padding-bottom:66.6%}.aspect-ratio--4x6-ns{padding-bottom:150%}.aspect-ratio--8x5-ns{padding-bottom:62.5%}.aspect-ratio--5x8-ns{padding-bottom:160%}.aspect-ratio--7x5-ns{padding-bottom:71.42%}.aspect-ratio--5x7-ns{padding-bottom:140%}.aspect-ratio--1x1-ns{padding-bottom:100%}.aspect-ratio--object-ns{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-ns{background-size:cover!important}.contain-ns{background-size:contain!important}.bg-center-ns{background-position:50%}.bg-center-ns,.bg-top-ns{background-repeat:no-repeat}.bg-top-ns{background-position:top}.bg-right-ns{background-position:100%}.bg-bottom-ns,.bg-right-ns{background-repeat:no-repeat}.bg-bottom-ns{background-position:bottom}.bg-left-ns{background-repeat:no-repeat;background-position:0}.outline-ns{outline:1px solid}.outline-transparent-ns{outline:1px solid transparent}.outline-0-ns{outline:0}.ba-ns{border-style:solid;border-width:1px}.bt-ns{border-top-style:solid;border-top-width:1px}.br-ns{border-right-style:solid;border-right-width:1px}.bb-ns{border-bottom-style:solid;border-bottom-width:1px}.bl-ns{border-left-style:solid;border-left-width:1px}.bn-ns{border-style:none;border-width:0}.br0-ns{border-radius:0}.br1-ns{border-radius:.125rem}.br2-ns{border-radius:.25rem}.br3-ns{border-radius:.5rem}.br4-ns{border-radius:1rem}.br-100-ns{border-radius:100%}.br-pill-ns{border-radius:9999px}.br--bottom-ns{border-top-left-radius:0;border-top-right-radius:0}.br--top-ns{border-bottom-right-radius:0}.br--right-ns,.br--top-ns{border-bottom-left-radius:0}.br--right-ns{border-top-left-radius:0}.br--left-ns{border-top-right-radius:0;border-bottom-right-radius:0}.br-inherit-ns{border-radius:inherit}.br-initial-ns{border-radius:initial}.br-unset-ns{border-radius:unset}.b--dotted-ns{border-style:dotted}.b--dashed-ns{border-style:dashed}.b--solid-ns{border-style:solid}.b--none-ns{border-style:none}.bw0-ns{border-width:0}.bw1-ns{border-width:.125rem}.bw2-ns{border-width:.25rem}.bw3-ns{border-width:.5rem}.bw4-ns{border-width:1rem}.bw5-ns{border-width:2rem}.bt-0-ns{border-top-width:0}.br-0-ns{border-right-width:0}.bb-0-ns{border-bottom-width:0}.bl-0-ns{border-left-width:0}.shadow-1-ns{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-ns{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-ns{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-ns{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-ns{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-ns{top:0}.left-0-ns{left:0}.right-0-ns{right:0}.bottom-0-ns{bottom:0}.top-1-ns{top:1rem}.left-1-ns{left:1rem}.right-1-ns{right:1rem}.bottom-1-ns{bottom:1rem}.top-2-ns{top:2rem}.left-2-ns{left:2rem}.right-2-ns{right:2rem}.bottom-2-ns{bottom:2rem}.top--1-ns{top:-1rem}.right--1-ns{right:-1rem}.bottom--1-ns{bottom:-1rem}.left--1-ns{left:-1rem}.top--2-ns{top:-2rem}.right--2-ns{right:-2rem}.bottom--2-ns{bottom:-2rem}.left--2-ns{left:-2rem}.absolute--fill-ns{top:0;right:0;bottom:0;left:0}.cl-ns{clear:left}.cr-ns{clear:right}.cb-ns{clear:both}.cn-ns{clear:none}.dn-ns{display:none}.di-ns{display:inline}.db-ns{display:block}.dib-ns{display:inline-block}.dit-ns{display:inline-table}.dt-ns{display:table}.dtc-ns{display:table-cell}.dt-row-ns{display:table-row}.dt-row-group-ns{display:table-row-group}.dt-column-ns{display:table-column}.dt-column-group-ns{display:table-column-group}.dt--fixed-ns{table-layout:fixed;width:100%}.flex-ns{display:flex}.inline-flex-ns{display:inline-flex}.flex-auto-ns{flex:1 1 auto;min-width:0;min-height:0}.flex-none-ns{flex:none}.flex-column-ns{flex-direction:column}.flex-row-ns{flex-direction:row}.flex-wrap-ns{flex-wrap:wrap}.flex-nowrap-ns{flex-wrap:nowrap}.flex-wrap-reverse-ns{flex-wrap:wrap-reverse}.flex-column-reverse-ns{flex-direction:column-reverse}.flex-row-reverse-ns{flex-direction:row-reverse}.items-start-ns{align-items:flex-start}.items-end-ns{align-items:flex-end}.items-center-ns{align-items:center}.items-baseline-ns{align-items:baseline}.items-stretch-ns{align-items:stretch}.self-start-ns{align-self:flex-start}.self-end-ns{align-self:flex-end}.self-center-ns{align-self:center}.self-baseline-ns{align-self:baseline}.self-stretch-ns{align-self:stretch}.justify-start-ns{justify-content:flex-start}.justify-end-ns{justify-content:flex-end}.justify-center-ns{justify-content:center}.justify-between-ns{justify-content:space-between}.justify-around-ns{justify-content:space-around}.content-start-ns{align-content:flex-start}.content-end-ns{align-content:flex-end}.content-center-ns{align-content:center}.content-between-ns{align-content:space-between}.content-around-ns{align-content:space-around}.content-stretch-ns{align-content:stretch}.order-0-ns{order:0}.order-1-ns{order:1}.order-2-ns{order:2}.order-3-ns{order:3}.order-4-ns{order:4}.order-5-ns{order:5}.order-6-ns{order:6}.order-7-ns{order:7}.order-8-ns{order:8}.order-last-ns{order:99999}.flex-grow-0-ns{flex-grow:0}.flex-grow-1-ns{flex-grow:1}.flex-shrink-0-ns{flex-shrink:0}.flex-shrink-1-ns{flex-shrink:1}.fl-ns{float:left}.fl-ns,.fr-ns{_display:inline}.fr-ns{float:right}.fn-ns{float:none}.i-ns{font-style:italic}.fs-normal-ns{font-style:normal}.normal-ns{font-weight:400}.b-ns{font-weight:700}.fw1-ns{font-weight:100}.fw2-ns{font-weight:200}.fw3-ns{font-weight:300}.fw4-ns{font-weight:400}.fw5-ns{font-weight:500}.fw6-ns{font-weight:600}.fw7-ns{font-weight:700}.fw8-ns{font-weight:800}.fw9-ns{font-weight:900}.h1-ns{height:1rem}.h2-ns{height:2rem}.h3-ns{height:4rem}.h4-ns{height:8rem}.h5-ns{height:16rem}.h-25-ns{height:25%}.h-50-ns{height:50%}.h-75-ns{height:75%}.h-100-ns{height:100%}.min-h-100-ns{min-height:100%}.vh-25-ns{height:25vh}.vh-50-ns{height:50vh}.vh-75-ns{height:75vh}.vh-100-ns{height:100vh}.min-vh-100-ns{min-height:100vh}.h-auto-ns{height:auto}.h-inherit-ns{height:inherit}.tracked-ns{letter-spacing:.1em}.tracked-tight-ns{letter-spacing:-.05em}.tracked-mega-ns{letter-spacing:.25em}.lh-solid-ns{line-height:1}.lh-title-ns{line-height:1.25}.lh-copy-ns{line-height:1.5}.mw-100-ns{max-width:100%}.mw1-ns{max-width:1rem}.mw2-ns{max-width:2rem}.mw3-ns{max-width:4rem}.mw4-ns{max-width:8rem}.mw5-ns{max-width:16rem}.mw6-ns{max-width:32rem}.mw7-ns{max-width:48rem}.mw8-ns{max-width:64rem}.mw9-ns{max-width:96rem}.mw-none-ns{max-width:none}.w1-ns{width:1rem}.w2-ns{width:2rem}.w3-ns{width:4rem}.w4-ns{width:8rem}.w5-ns{width:16rem}.w-10-ns{width:10%}.w-20-ns{width:20%}.w-25-ns{width:25%}.w-30-ns{width:30%}.w-33-ns{width:33%}.w-34-ns{width:34%}.w-40-ns{width:40%}.w-50-ns{width:50%}.w-60-ns{width:60%}.w-70-ns{width:70%}.w-75-ns{width:75%}.w-80-ns{width:80%}.w-90-ns{width:90%}.w-100-ns{width:100%}.w-third-ns{width:33.33333%}.w-two-thirds-ns{width:66.66667%}.w-auto-ns{width:auto}.overflow-visible-ns{overflow:visible}.overflow-hidden-ns{overflow:hidden}.overflow-scroll-ns{overflow:scroll}.overflow-auto-ns{overflow:auto}.overflow-x-visible-ns{overflow-x:visible}.overflow-x-hidden-ns{overflow-x:hidden}.overflow-x-scroll-ns{overflow-x:scroll}.overflow-x-auto-ns{overflow-x:auto}.overflow-y-visible-ns{overflow-y:visible}.overflow-y-hidden-ns{overflow-y:hidden}.overflow-y-scroll-ns{overflow-y:scroll}.overflow-y-auto-ns{overflow-y:auto}.static-ns{position:static}.relative-ns{position:relative}.absolute-ns{position:absolute}.fixed-ns{position:fixed}.rotate-45-ns{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-ns{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-ns{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-ns{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-ns{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-ns{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-ns{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-ns{padding:0}.pa1-ns{padding:.25rem}.pa2-ns{padding:.5rem}.pa3-ns{padding:1rem}.pa4-ns{padding:2rem}.pa5-ns{padding:4rem}.pa6-ns{padding:8rem}.pa7-ns{padding:16rem}.pl0-ns{padding-left:0}.pl1-ns{padding-left:.25rem}.pl2-ns{padding-left:.5rem}.pl3-ns{padding-left:1rem}.pl4-ns{padding-left:2rem}.pl5-ns{padding-left:4rem}.pl6-ns{padding-left:8rem}.pl7-ns{padding-left:16rem}.pr0-ns{padding-right:0}.pr1-ns{padding-right:.25rem}.pr2-ns{padding-right:.5rem}.pr3-ns{padding-right:1rem}.pr4-ns{padding-right:2rem}.pr5-ns{padding-right:4rem}.pr6-ns{padding-right:8rem}.pr7-ns{padding-right:16rem}.pb0-ns{padding-bottom:0}.pb1-ns{padding-bottom:.25rem}.pb2-ns{padding-bottom:.5rem}.pb3-ns{padding-bottom:1rem}.pb4-ns{padding-bottom:2rem}.pb5-ns{padding-bottom:4rem}.pb6-ns{padding-bottom:8rem}.pb7-ns{padding-bottom:16rem}.pt0-ns{padding-top:0}.pt1-ns{padding-top:.25rem}.pt2-ns{padding-top:.5rem}.pt3-ns{padding-top:1rem}.pt4-ns{padding-top:2rem}.pt5-ns{padding-top:4rem}.pt6-ns{padding-top:8rem}.pt7-ns{padding-top:16rem}.pv0-ns{padding-top:0;padding-bottom:0}.pv1-ns{padding-top:.25rem;padding-bottom:.25rem}.pv2-ns{padding-top:.5rem;padding-bottom:.5rem}.pv3-ns{padding-top:1rem;padding-bottom:1rem}.pv4-ns{padding-top:2rem;padding-bottom:2rem}.pv5-ns{padding-top:4rem;padding-bottom:4rem}.pv6-ns{padding-top:8rem;padding-bottom:8rem}.pv7-ns{padding-top:16rem;padding-bottom:16rem}.ph0-ns{padding-left:0;padding-right:0}.ph1-ns{padding-left:.25rem;padding-right:.25rem}.ph2-ns{padding-left:.5rem;padding-right:.5rem}.ph3-ns{padding-left:1rem;padding-right:1rem}.ph4-ns{padding-left:2rem;padding-right:2rem}.ph5-ns{padding-left:4rem;padding-right:4rem}.ph6-ns{padding-left:8rem;padding-right:8rem}.ph7-ns{padding-left:16rem;padding-right:16rem}.ma0-ns{margin:0}.ma1-ns{margin:.25rem}.ma2-ns{margin:.5rem}.ma3-ns{margin:1rem}.ma4-ns{margin:2rem}.ma5-ns{margin:4rem}.ma6-ns{margin:8rem}.ma7-ns{margin:16rem}.ml0-ns{margin-left:0}.ml1-ns{margin-left:.25rem}.ml2-ns{margin-left:.5rem}.ml3-ns{margin-left:1rem}.ml4-ns{margin-left:2rem}.ml5-ns{margin-left:4rem}.ml6-ns{margin-left:8rem}.ml7-ns{margin-left:16rem}.mr0-ns{margin-right:0}.mr1-ns{margin-right:.25rem}.mr2-ns{margin-right:.5rem}.mr3-ns{margin-right:1rem}.mr4-ns{margin-right:2rem}.mr5-ns{margin-right:4rem}.mr6-ns{margin-right:8rem}.mr7-ns{margin-right:16rem}.mb0-ns{margin-bottom:0}.mb1-ns{margin-bottom:.25rem}.mb2-ns{margin-bottom:.5rem}.mb3-ns{margin-bottom:1rem}.mb4-ns{margin-bottom:2rem}.mb5-ns{margin-bottom:4rem}.mb6-ns{margin-bottom:8rem}.mb7-ns{margin-bottom:16rem}.mt0-ns{margin-top:0}.mt1-ns{margin-top:.25rem}.mt2-ns{margin-top:.5rem}.mt3-ns{margin-top:1rem}.mt4-ns{margin-top:2rem}.mt5-ns{margin-top:4rem}.mt6-ns{margin-top:8rem}.mt7-ns{margin-top:16rem}.mv0-ns{margin-top:0;margin-bottom:0}.mv1-ns{margin-top:.25rem;margin-bottom:.25rem}.mv2-ns{margin-top:.5rem;margin-bottom:.5rem}.mv3-ns{margin-top:1rem;margin-bottom:1rem}.mv4-ns{margin-top:2rem;margin-bottom:2rem}.mv5-ns{margin-top:4rem;margin-bottom:4rem}.mv6-ns{margin-top:8rem;margin-bottom:8rem}.mv7-ns{margin-top:16rem;margin-bottom:16rem}.mh0-ns{margin-left:0;margin-right:0}.mh1-ns{margin-left:.25rem;margin-right:.25rem}.mh2-ns{margin-left:.5rem;margin-right:.5rem}.mh3-ns{margin-left:1rem;margin-right:1rem}.mh4-ns{margin-left:2rem;margin-right:2rem}.mh5-ns{margin-left:4rem;margin-right:4rem}.mh6-ns{margin-left:8rem;margin-right:8rem}.mh7-ns{margin-left:16rem;margin-right:16rem}.na1-ns{margin:-.25rem}.na2-ns{margin:-.5rem}.na3-ns{margin:-1rem}.na4-ns{margin:-2rem}.na5-ns{margin:-4rem}.na6-ns{margin:-8rem}.na7-ns{margin:-16rem}.nl1-ns{margin-left:-.25rem}.nl2-ns{margin-left:-.5rem}.nl3-ns{margin-left:-1rem}.nl4-ns{margin-left:-2rem}.nl5-ns{margin-left:-4rem}.nl6-ns{margin-left:-8rem}.nl7-ns{margin-left:-16rem}.nr1-ns{margin-right:-.25rem}.nr2-ns{margin-right:-.5rem}.nr3-ns{margin-right:-1rem}.nr4-ns{margin-right:-2rem}.nr5-ns{margin-right:-4rem}.nr6-ns{margin-right:-8rem}.nr7-ns{margin-right:-16rem}.nb1-ns{margin-bottom:-.25rem}.nb2-ns{margin-bottom:-.5rem}.nb3-ns{margin-bottom:-1rem}.nb4-ns{margin-bottom:-2rem}.nb5-ns{margin-bottom:-4rem}.nb6-ns{margin-bottom:-8rem}.nb7-ns{margin-bottom:-16rem}.nt1-ns{margin-top:-.25rem}.nt2-ns{margin-top:-.5rem}.nt3-ns{margin-top:-1rem}.nt4-ns{margin-top:-2rem}.nt5-ns{margin-top:-4rem}.nt6-ns{margin-top:-8rem}.nt7-ns{margin-top:-16rem}.strike-ns{text-decoration:line-through}.underline-ns{text-decoration:underline}.no-underline-ns{text-decoration:none}.tl-ns{text-align:left}.tr-ns{text-align:right}.tc-ns{text-align:center}.tj-ns{text-align:justify}.ttc-ns{text-transform:capitalize}.ttl-ns{text-transform:lowercase}.ttu-ns{text-transform:uppercase}.ttn-ns{text-transform:none}.f-6-ns,.f-headline-ns{font-size:6rem}.f-5-ns,.f-subheadline-ns{font-size:5rem}.f1-ns{font-size:3rem}.f2-ns{font-size:2.25rem}.f3-ns{font-size:1.5rem}.f4-ns{font-size:1.25rem}.f5-ns{font-size:1rem}.f6-ns{font-size:.875rem}.f7-ns{font-size:.75rem}.measure-ns{max-width:30em}.measure-wide-ns{max-width:34em}.measure-narrow-ns{max-width:20em}.indent-ns{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-ns{font-variant:small-caps}.truncate-ns{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-ns{margin-left:auto}.center-ns,.mr-auto-ns{margin-right:auto}.ml-auto-ns{margin-left:auto}.clip-ns{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-ns{white-space:normal}.nowrap-ns{white-space:nowrap}.pre-ns{white-space:pre}.v-base-ns{vertical-align:baseline}.v-mid-ns{vertical-align:middle}.v-top-ns{vertical-align:top}.v-btm-ns{vertical-align:bottom}}@media screen and (min-width:30em) and (max-width:60em){.aspect-ratio-m{height:0;position:relative}.aspect-ratio--16x9-m{padding-bottom:56.25%}.aspect-ratio--9x16-m{padding-bottom:177.77%}.aspect-ratio--4x3-m{padding-bottom:75%}.aspect-ratio--3x4-m{padding-bottom:133.33%}.aspect-ratio--6x4-m{padding-bottom:66.6%}.aspect-ratio--4x6-m{padding-bottom:150%}.aspect-ratio--8x5-m{padding-bottom:62.5%}.aspect-ratio--5x8-m{padding-bottom:160%}.aspect-ratio--7x5-m{padding-bottom:71.42%}.aspect-ratio--5x7-m{padding-bottom:140%}.aspect-ratio--1x1-m{padding-bottom:100%}.aspect-ratio--object-m{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-m{background-size:cover!important}.contain-m{background-size:contain!important}.bg-center-m{background-position:50%}.bg-center-m,.bg-top-m{background-repeat:no-repeat}.bg-top-m{background-position:top}.bg-right-m{background-position:100%}.bg-bottom-m,.bg-right-m{background-repeat:no-repeat}.bg-bottom-m{background-position:bottom}.bg-left-m{background-repeat:no-repeat;background-position:0}.outline-m{outline:1px solid}.outline-transparent-m{outline:1px solid transparent}.outline-0-m{outline:0}.ba-m{border-style:solid;border-width:1px}.bt-m{border-top-style:solid;border-top-width:1px}.br-m{border-right-style:solid;border-right-width:1px}.bb-m{border-bottom-style:solid;border-bottom-width:1px}.bl-m{border-left-style:solid;border-left-width:1px}.bn-m{border-style:none;border-width:0}.br0-m{border-radius:0}.br1-m{border-radius:.125rem}.br2-m{border-radius:.25rem}.br3-m{border-radius:.5rem}.br4-m{border-radius:1rem}.br-100-m{border-radius:100%}.br-pill-m{border-radius:9999px}.br--bottom-m{border-top-left-radius:0;border-top-right-radius:0}.br--top-m{border-bottom-right-radius:0}.br--right-m,.br--top-m{border-bottom-left-radius:0}.br--right-m{border-top-left-radius:0}.br--left-m{border-top-right-radius:0;border-bottom-right-radius:0}.br-inherit-m{border-radius:inherit}.br-initial-m{border-radius:initial}.br-unset-m{border-radius:unset}.b--dotted-m{border-style:dotted}.b--dashed-m{border-style:dashed}.b--solid-m{border-style:solid}.b--none-m{border-style:none}.bw0-m{border-width:0}.bw1-m{border-width:.125rem}.bw2-m{border-width:.25rem}.bw3-m{border-width:.5rem}.bw4-m{border-width:1rem}.bw5-m{border-width:2rem}.bt-0-m{border-top-width:0}.br-0-m{border-right-width:0}.bb-0-m{border-bottom-width:0}.bl-0-m{border-left-width:0}.shadow-1-m{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-m{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-m{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-m{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-m{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-m{top:0}.left-0-m{left:0}.right-0-m{right:0}.bottom-0-m{bottom:0}.top-1-m{top:1rem}.left-1-m{left:1rem}.right-1-m{right:1rem}.bottom-1-m{bottom:1rem}.top-2-m{top:2rem}.left-2-m{left:2rem}.right-2-m{right:2rem}.bottom-2-m{bottom:2rem}.top--1-m{top:-1rem}.right--1-m{right:-1rem}.bottom--1-m{bottom:-1rem}.left--1-m{left:-1rem}.top--2-m{top:-2rem}.right--2-m{right:-2rem}.bottom--2-m{bottom:-2rem}.left--2-m{left:-2rem}.absolute--fill-m{top:0;right:0;bottom:0;left:0}.cl-m{clear:left}.cr-m{clear:right}.cb-m{clear:both}.cn-m{clear:none}.dn-m{display:none}.di-m{display:inline}.db-m{display:block}.dib-m{display:inline-block}.dit-m{display:inline-table}.dt-m{display:table}.dtc-m{display:table-cell}.dt-row-m{display:table-row}.dt-row-group-m{display:table-row-group}.dt-column-m{display:table-column}.dt-column-group-m{display:table-column-group}.dt--fixed-m{table-layout:fixed;width:100%}.flex-m{display:flex}.inline-flex-m{display:inline-flex}.flex-auto-m{flex:1 1 auto;min-width:0;min-height:0}.flex-none-m{flex:none}.flex-column-m{flex-direction:column}.flex-row-m{flex-direction:row}.flex-wrap-m{flex-wrap:wrap}.flex-nowrap-m{flex-wrap:nowrap}.flex-wrap-reverse-m{flex-wrap:wrap-reverse}.flex-column-reverse-m{flex-direction:column-reverse}.flex-row-reverse-m{flex-direction:row-reverse}.items-start-m{align-items:flex-start}.items-end-m{align-items:flex-end}.items-center-m{align-items:center}.items-baseline-m{align-items:baseline}.items-stretch-m{align-items:stretch}.self-start-m{align-self:flex-start}.self-end-m{align-self:flex-end}.self-center-m{align-self:center}.self-baseline-m{align-self:baseline}.self-stretch-m{align-self:stretch}.justify-start-m{justify-content:flex-start}.justify-end-m{justify-content:flex-end}.justify-center-m{justify-content:center}.justify-between-m{justify-content:space-between}.justify-around-m{justify-content:space-around}.content-start-m{align-content:flex-start}.content-end-m{align-content:flex-end}.content-center-m{align-content:center}.content-between-m{align-content:space-between}.content-around-m{align-content:space-around}.content-stretch-m{align-content:stretch}.order-0-m{order:0}.order-1-m{order:1}.order-2-m{order:2}.order-3-m{order:3}.order-4-m{order:4}.order-5-m{order:5}.order-6-m{order:6}.order-7-m{order:7}.order-8-m{order:8}.order-last-m{order:99999}.flex-grow-0-m{flex-grow:0}.flex-grow-1-m{flex-grow:1}.flex-shrink-0-m{flex-shrink:0}.flex-shrink-1-m{flex-shrink:1}.fl-m{float:left}.fl-m,.fr-m{_display:inline}.fr-m{float:right}.fn-m{float:none}.i-m{font-style:italic}.fs-normal-m{font-style:normal}.normal-m{font-weight:400}.b-m{font-weight:700}.fw1-m{font-weight:100}.fw2-m{font-weight:200}.fw3-m{font-weight:300}.fw4-m{font-weight:400}.fw5-m{font-weight:500}.fw6-m{font-weight:600}.fw7-m{font-weight:700}.fw8-m{font-weight:800}.fw9-m{font-weight:900}.h1-m{height:1rem}.h2-m{height:2rem}.h3-m{height:4rem}.h4-m{height:8rem}.h5-m{height:16rem}.h-25-m{height:25%}.h-50-m{height:50%}.h-75-m{height:75%}.h-100-m{height:100%}.min-h-100-m{min-height:100%}.vh-25-m{height:25vh}.vh-50-m{height:50vh}.vh-75-m{height:75vh}.vh-100-m{height:100vh}.min-vh-100-m{min-height:100vh}.h-auto-m{height:auto}.h-inherit-m{height:inherit}.tracked-m{letter-spacing:.1em}.tracked-tight-m{letter-spacing:-.05em}.tracked-mega-m{letter-spacing:.25em}.lh-solid-m{line-height:1}.lh-title-m{line-height:1.25}.lh-copy-m{line-height:1.5}.mw-100-m{max-width:100%}.mw1-m{max-width:1rem}.mw2-m{max-width:2rem}.mw3-m{max-width:4rem}.mw4-m{max-width:8rem}.mw5-m{max-width:16rem}.mw6-m{max-width:32rem}.mw7-m{max-width:48rem}.mw8-m{max-width:64rem}.mw9-m{max-width:96rem}.mw-none-m{max-width:none}.w1-m{width:1rem}.w2-m{width:2rem}.w3-m{width:4rem}.w4-m{width:8rem}.w5-m{width:16rem}.w-10-m{width:10%}.w-20-m{width:20%}.w-25-m{width:25%}.w-30-m{width:30%}.w-33-m{width:33%}.w-34-m{width:34%}.w-40-m{width:40%}.w-50-m{width:50%}.w-60-m{width:60%}.w-70-m{width:70%}.w-75-m{width:75%}.w-80-m{width:80%}.w-90-m{width:90%}.w-100-m{width:100%}.w-third-m{width:33.33333%}.w-two-thirds-m{width:66.66667%}.w-auto-m{width:auto}.overflow-visible-m{overflow:visible}.overflow-hidden-m{overflow:hidden}.overflow-scroll-m{overflow:scroll}.overflow-auto-m{overflow:auto}.overflow-x-visible-m{overflow-x:visible}.overflow-x-hidden-m{overflow-x:hidden}.overflow-x-scroll-m{overflow-x:scroll}.overflow-x-auto-m{overflow-x:auto}.overflow-y-visible-m{overflow-y:visible}.overflow-y-hidden-m{overflow-y:hidden}.overflow-y-scroll-m{overflow-y:scroll}.overflow-y-auto-m{overflow-y:auto}.static-m{position:static}.relative-m{position:relative}.absolute-m{position:absolute}.fixed-m{position:fixed}.rotate-45-m{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-m{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-m{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-m{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-m{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-m{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-m{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-m{padding:0}.pa1-m{padding:.25rem}.pa2-m{padding:.5rem}.pa3-m{padding:1rem}.pa4-m{padding:2rem}.pa5-m{padding:4rem}.pa6-m{padding:8rem}.pa7-m{padding:16rem}.pl0-m{padding-left:0}.pl1-m{padding-left:.25rem}.pl2-m{padding-left:.5rem}.pl3-m{padding-left:1rem}.pl4-m{padding-left:2rem}.pl5-m{padding-left:4rem}.pl6-m{padding-left:8rem}.pl7-m{padding-left:16rem}.pr0-m{padding-right:0}.pr1-m{padding-right:.25rem}.pr2-m{padding-right:.5rem}.pr3-m{padding-right:1rem}.pr4-m{padding-right:2rem}.pr5-m{padding-right:4rem}.pr6-m{padding-right:8rem}.pr7-m{padding-right:16rem}.pb0-m{padding-bottom:0}.pb1-m{padding-bottom:.25rem}.pb2-m{padding-bottom:.5rem}.pb3-m{padding-bottom:1rem}.pb4-m{padding-bottom:2rem}.pb5-m{padding-bottom:4rem}.pb6-m{padding-bottom:8rem}.pb7-m{padding-bottom:16rem}.pt0-m{padding-top:0}.pt1-m{padding-top:.25rem}.pt2-m{padding-top:.5rem}.pt3-m{padding-top:1rem}.pt4-m{padding-top:2rem}.pt5-m{padding-top:4rem}.pt6-m{padding-top:8rem}.pt7-m{padding-top:16rem}.pv0-m{padding-top:0;padding-bottom:0}.pv1-m{padding-top:.25rem;padding-bottom:.25rem}.pv2-m{padding-top:.5rem;padding-bottom:.5rem}.pv3-m{padding-top:1rem;padding-bottom:1rem}.pv4-m{padding-top:2rem;padding-bottom:2rem}.pv5-m{padding-top:4rem;padding-bottom:4rem}.pv6-m{padding-top:8rem;padding-bottom:8rem}.pv7-m{padding-top:16rem;padding-bottom:16rem}.ph0-m{padding-left:0;padding-right:0}.ph1-m{padding-left:.25rem;padding-right:.25rem}.ph2-m{padding-left:.5rem;padding-right:.5rem}.ph3-m{padding-left:1rem;padding-right:1rem}.ph4-m{padding-left:2rem;padding-right:2rem}.ph5-m{padding-left:4rem;padding-right:4rem}.ph6-m{padding-left:8rem;padding-right:8rem}.ph7-m{padding-left:16rem;padding-right:16rem}.ma0-m{margin:0}.ma1-m{margin:.25rem}.ma2-m{margin:.5rem}.ma3-m{margin:1rem}.ma4-m{margin:2rem}.ma5-m{margin:4rem}.ma6-m{margin:8rem}.ma7-m{margin:16rem}.ml0-m{margin-left:0}.ml1-m{margin-left:.25rem}.ml2-m{margin-left:.5rem}.ml3-m{margin-left:1rem}.ml4-m{margin-left:2rem}.ml5-m{margin-left:4rem}.ml6-m{margin-left:8rem}.ml7-m{margin-left:16rem}.mr0-m{margin-right:0}.mr1-m{margin-right:.25rem}.mr2-m{margin-right:.5rem}.mr3-m{margin-right:1rem}.mr4-m{margin-right:2rem}.mr5-m{margin-right:4rem}.mr6-m{margin-right:8rem}.mr7-m{margin-right:16rem}.mb0-m{margin-bottom:0}.mb1-m{margin-bottom:.25rem}.mb2-m{margin-bottom:.5rem}.mb3-m{margin-bottom:1rem}.mb4-m{margin-bottom:2rem}.mb5-m{margin-bottom:4rem}.mb6-m{margin-bottom:8rem}.mb7-m{margin-bottom:16rem}.mt0-m{margin-top:0}.mt1-m{margin-top:.25rem}.mt2-m{margin-top:.5rem}.mt3-m{margin-top:1rem}.mt4-m{margin-top:2rem}.mt5-m{margin-top:4rem}.mt6-m{margin-top:8rem}.mt7-m{margin-top:16rem}.mv0-m{margin-top:0;margin-bottom:0}.mv1-m{margin-top:.25rem;margin-bottom:.25rem}.mv2-m{margin-top:.5rem;margin-bottom:.5rem}.mv3-m{margin-top:1rem;margin-bottom:1rem}.mv4-m{margin-top:2rem;margin-bottom:2rem}.mv5-m{margin-top:4rem;margin-bottom:4rem}.mv6-m{margin-top:8rem;margin-bottom:8rem}.mv7-m{margin-top:16rem;margin-bottom:16rem}.mh0-m{margin-left:0;margin-right:0}.mh1-m{margin-left:.25rem;margin-right:.25rem}.mh2-m{margin-left:.5rem;margin-right:.5rem}.mh3-m{margin-left:1rem;margin-right:1rem}.mh4-m{margin-left:2rem;margin-right:2rem}.mh5-m{margin-left:4rem;margin-right:4rem}.mh6-m{margin-left:8rem;margin-right:8rem}.mh7-m{margin-left:16rem;margin-right:16rem}.na1-m{margin:-.25rem}.na2-m{margin:-.5rem}.na3-m{margin:-1rem}.na4-m{margin:-2rem}.na5-m{margin:-4rem}.na6-m{margin:-8rem}.na7-m{margin:-16rem}.nl1-m{margin-left:-.25rem}.nl2-m{margin-left:-.5rem}.nl3-m{margin-left:-1rem}.nl4-m{margin-left:-2rem}.nl5-m{margin-left:-4rem}.nl6-m{margin-left:-8rem}.nl7-m{margin-left:-16rem}.nr1-m{margin-right:-.25rem}.nr2-m{margin-right:-.5rem}.nr3-m{margin-right:-1rem}.nr4-m{margin-right:-2rem}.nr5-m{margin-right:-4rem}.nr6-m{margin-right:-8rem}.nr7-m{margin-right:-16rem}.nb1-m{margin-bottom:-.25rem}.nb2-m{margin-bottom:-.5rem}.nb3-m{margin-bottom:-1rem}.nb4-m{margin-bottom:-2rem}.nb5-m{margin-bottom:-4rem}.nb6-m{margin-bottom:-8rem}.nb7-m{margin-bottom:-16rem}.nt1-m{margin-top:-.25rem}.nt2-m{margin-top:-.5rem}.nt3-m{margin-top:-1rem}.nt4-m{margin-top:-2rem}.nt5-m{margin-top:-4rem}.nt6-m{margin-top:-8rem}.nt7-m{margin-top:-16rem}.strike-m{text-decoration:line-through}.underline-m{text-decoration:underline}.no-underline-m{text-decoration:none}.tl-m{text-align:left}.tr-m{text-align:right}.tc-m{text-align:center}.tj-m{text-align:justify}.ttc-m{text-transform:capitalize}.ttl-m{text-transform:lowercase}.ttu-m{text-transform:uppercase}.ttn-m{text-transform:none}.f-6-m,.f-headline-m{font-size:6rem}.f-5-m,.f-subheadline-m{font-size:5rem}.f1-m{font-size:3rem}.f2-m{font-size:2.25rem}.f3-m{font-size:1.5rem}.f4-m{font-size:1.25rem}.f5-m{font-size:1rem}.f6-m{font-size:.875rem}.f7-m{font-size:.75rem}.measure-m{max-width:30em}.measure-wide-m{max-width:34em}.measure-narrow-m{max-width:20em}.indent-m{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-m{font-variant:small-caps}.truncate-m{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-m{margin-left:auto}.center-m,.mr-auto-m{margin-right:auto}.ml-auto-m{margin-left:auto}.clip-m{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-m{white-space:normal}.nowrap-m{white-space:nowrap}.pre-m{white-space:pre}.v-base-m{vertical-align:baseline}.v-mid-m{vertical-align:middle}.v-top-m{vertical-align:top}.v-btm-m{vertical-align:bottom}}@media screen and (min-width:60em){.aspect-ratio-l{height:0;position:relative}.aspect-ratio--16x9-l{padding-bottom:56.25%}.aspect-ratio--9x16-l{padding-bottom:177.77%}.aspect-ratio--4x3-l{padding-bottom:75%}.aspect-ratio--3x4-l{padding-bottom:133.33%}.aspect-ratio--6x4-l{padding-bottom:66.6%}.aspect-ratio--4x6-l{padding-bottom:150%}.aspect-ratio--8x5-l{padding-bottom:62.5%}.aspect-ratio--5x8-l{padding-bottom:160%}.aspect-ratio--7x5-l{padding-bottom:71.42%}.aspect-ratio--5x7-l{padding-bottom:140%}.aspect-ratio--1x1-l{padding-bottom:100%}.aspect-ratio--object-l{position:absolute;top:0;right:0;bottom:0;left:0;width:100%;height:100%;z-index:100}.cover-l{background-size:cover!important}.contain-l{background-size:contain!important}.bg-center-l{background-position:50%}.bg-center-l,.bg-top-l{background-repeat:no-repeat}.bg-top-l{background-position:top}.bg-right-l{background-position:100%}.bg-bottom-l,.bg-right-l{background-repeat:no-repeat}.bg-bottom-l{background-position:bottom}.bg-left-l{background-repeat:no-repeat;background-position:0}.outline-l{outline:1px solid}.outline-transparent-l{outline:1px solid transparent}.outline-0-l{outline:0}.ba-l{border-style:solid;border-width:1px}.bt-l{border-top-style:solid;border-top-width:1px}.br-l{border-right-style:solid;border-right-width:1px}.bb-l{border-bottom-style:solid;border-bottom-width:1px}.bl-l{border-left-style:solid;border-left-width:1px}.bn-l{border-style:none;border-width:0}.br0-l{border-radius:0}.br1-l{border-radius:.125rem}.br2-l{border-radius:.25rem}.br3-l{border-radius:.5rem}.br4-l{border-radius:1rem}.br-100-l{border-radius:100%}.br-pill-l{border-radius:9999px}.br--bottom-l{border-top-left-radius:0;border-top-right-radius:0}.br--top-l{border-bottom-right-radius:0}.br--right-l,.br--top-l{border-bottom-left-radius:0}.br--right-l{border-top-left-radius:0}.br--left-l{border-top-right-radius:0;border-bottom-right-radius:0}.br-inherit-l{border-radius:inherit}.br-initial-l{border-radius:initial}.br-unset-l{border-radius:unset}.b--dotted-l{border-style:dotted}.b--dashed-l{border-style:dashed}.b--solid-l{border-style:solid}.b--none-l{border-style:none}.bw0-l{border-width:0}.bw1-l{border-width:.125rem}.bw2-l{border-width:.25rem}.bw3-l{border-width:.5rem}.bw4-l{border-width:1rem}.bw5-l{border-width:2rem}.bt-0-l{border-top-width:0}.br-0-l{border-right-width:0}.bb-0-l{border-bottom-width:0}.bl-0-l{border-left-width:0}.shadow-1-l{box-shadow:0 0 4px 2px rgba(0,0,0,.2)}.shadow-2-l{box-shadow:0 0 8px 2px rgba(0,0,0,.2)}.shadow-3-l{box-shadow:2px 2px 4px 2px rgba(0,0,0,.2)}.shadow-4-l{box-shadow:2px 2px 8px 0 rgba(0,0,0,.2)}.shadow-5-l{box-shadow:4px 4px 8px 0 rgba(0,0,0,.2)}.top-0-l{top:0}.left-0-l{left:0}.right-0-l{right:0}.bottom-0-l{bottom:0}.top-1-l{top:1rem}.left-1-l{left:1rem}.right-1-l{right:1rem}.bottom-1-l{bottom:1rem}.top-2-l{top:2rem}.left-2-l{left:2rem}.right-2-l{right:2rem}.bottom-2-l{bottom:2rem}.top--1-l{top:-1rem}.right--1-l{right:-1rem}.bottom--1-l{bottom:-1rem}.left--1-l{left:-1rem}.top--2-l{top:-2rem}.right--2-l{right:-2rem}.bottom--2-l{bottom:-2rem}.left--2-l{left:-2rem}.absolute--fill-l{top:0;right:0;bottom:0;left:0}.cl-l{clear:left}.cr-l{clear:right}.cb-l{clear:both}.cn-l{clear:none}.dn-l{display:none}.di-l{display:inline}.db-l{display:block}.dib-l{display:inline-block}.dit-l{display:inline-table}.dt-l{display:table}.dtc-l{display:table-cell}.dt-row-l{display:table-row}.dt-row-group-l{display:table-row-group}.dt-column-l{display:table-column}.dt-column-group-l{display:table-column-group}.dt--fixed-l{table-layout:fixed;width:100%}.flex-l{display:flex}.inline-flex-l{display:inline-flex}.flex-auto-l{flex:1 1 auto;min-width:0;min-height:0}.flex-none-l{flex:none}.flex-column-l{flex-direction:column}.flex-row-l{flex-direction:row}.flex-wrap-l{flex-wrap:wrap}.flex-nowrap-l{flex-wrap:nowrap}.flex-wrap-reverse-l{flex-wrap:wrap-reverse}.flex-column-reverse-l{flex-direction:column-reverse}.flex-row-reverse-l{flex-direction:row-reverse}.items-start-l{align-items:flex-start}.items-end-l{align-items:flex-end}.items-center-l{align-items:center}.items-baseline-l{align-items:baseline}.items-stretch-l{align-items:stretch}.self-start-l{align-self:flex-start}.self-end-l{align-self:flex-end}.self-center-l{align-self:center}.self-baseline-l{align-self:baseline}.self-stretch-l{align-self:stretch}.justify-start-l{justify-content:flex-start}.justify-end-l{justify-content:flex-end}.justify-center-l{justify-content:center}.justify-between-l{justify-content:space-between}.justify-around-l{justify-content:space-around}.content-start-l{align-content:flex-start}.content-end-l{align-content:flex-end}.content-center-l{align-content:center}.content-between-l{align-content:space-between}.content-around-l{align-content:space-around}.content-stretch-l{align-content:stretch}.order-0-l{order:0}.order-1-l{order:1}.order-2-l{order:2}.order-3-l{order:3}.order-4-l{order:4}.order-5-l{order:5}.order-6-l{order:6}.order-7-l{order:7}.order-8-l{order:8}.order-last-l{order:99999}.flex-grow-0-l{flex-grow:0}.flex-grow-1-l{flex-grow:1}.flex-shrink-0-l{flex-shrink:0}.flex-shrink-1-l{flex-shrink:1}.fl-l{float:left}.fl-l,.fr-l{_display:inline}.fr-l{float:right}.fn-l{float:none}.i-l{font-style:italic}.fs-normal-l{font-style:normal}.normal-l{font-weight:400}.b-l{font-weight:700}.fw1-l{font-weight:100}.fw2-l{font-weight:200}.fw3-l{font-weight:300}.fw4-l{font-weight:400}.fw5-l{font-weight:500}.fw6-l{font-weight:600}.fw7-l{font-weight:700}.fw8-l{font-weight:800}.fw9-l{font-weight:900}.h1-l{height:1rem}.h2-l{height:2rem}.h3-l{height:4rem}.h4-l{height:8rem}.h5-l{height:16rem}.h-25-l{height:25%}.h-50-l{height:50%}.h-75-l{height:75%}.h-100-l{height:100%}.min-h-100-l{min-height:100%}.vh-25-l{height:25vh}.vh-50-l{height:50vh}.vh-75-l{height:75vh}.vh-100-l{height:100vh}.min-vh-100-l{min-height:100vh}.h-auto-l{height:auto}.h-inherit-l{height:inherit}.tracked-l{letter-spacing:.1em}.tracked-tight-l{letter-spacing:-.05em}.tracked-mega-l{letter-spacing:.25em}.lh-solid-l{line-height:1}.lh-title-l{line-height:1.25}.lh-copy-l{line-height:1.5}.mw-100-l{max-width:100%}.mw1-l{max-width:1rem}.mw2-l{max-width:2rem}.mw3-l{max-width:4rem}.mw4-l{max-width:8rem}.mw5-l{max-width:16rem}.mw6-l{max-width:32rem}.mw7-l{max-width:48rem}.mw8-l{max-width:64rem}.mw9-l{max-width:96rem}.mw-none-l{max-width:none}.w1-l{width:1rem}.w2-l{width:2rem}.w3-l{width:4rem}.w4-l{width:8rem}.w5-l{width:16rem}.w-10-l{width:10%}.w-20-l{width:20%}.w-25-l{width:25%}.w-30-l{width:30%}.w-33-l{width:33%}.w-34-l{width:34%}.w-40-l{width:40%}.w-50-l{width:50%}.w-60-l{width:60%}.w-70-l{width:70%}.w-75-l{width:75%}.w-80-l{width:80%}.w-90-l{width:90%}.w-100-l{width:100%}.w-third-l{width:33.33333%}.w-two-thirds-l{width:66.66667%}.w-auto-l{width:auto}.overflow-visible-l{overflow:visible}.overflow-hidden-l{overflow:hidden}.overflow-scroll-l{overflow:scroll}.overflow-auto-l{overflow:auto}.overflow-x-visible-l{overflow-x:visible}.overflow-x-hidden-l{overflow-x:hidden}.overflow-x-scroll-l{overflow-x:scroll}.overflow-x-auto-l{overflow-x:auto}.overflow-y-visible-l{overflow-y:visible}.overflow-y-hidden-l{overflow-y:hidden}.overflow-y-scroll-l{overflow-y:scroll}.overflow-y-auto-l{overflow-y:auto}.static-l{position:static}.relative-l{position:relative}.absolute-l{position:absolute}.fixed-l{position:fixed}.rotate-45-l{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.rotate-90-l{-webkit-transform:rotate(90deg);transform:rotate(90deg)}.rotate-135-l{-webkit-transform:rotate(135deg);transform:rotate(135deg)}.rotate-180-l{-webkit-transform:rotate(180deg);transform:rotate(180deg)}.rotate-225-l{-webkit-transform:rotate(225deg);transform:rotate(225deg)}.rotate-270-l{-webkit-transform:rotate(270deg);transform:rotate(270deg)}.rotate-315-l{-webkit-transform:rotate(315deg);transform:rotate(315deg)}.pa0-l{padding:0}.pa1-l{padding:.25rem}.pa2-l{padding:.5rem}.pa3-l{padding:1rem}.pa4-l{padding:2rem}.pa5-l{padding:4rem}.pa6-l{padding:8rem}.pa7-l{padding:16rem}.pl0-l{padding-left:0}.pl1-l{padding-left:.25rem}.pl2-l{padding-left:.5rem}.pl3-l{padding-left:1rem}.pl4-l{padding-left:2rem}.pl5-l{padding-left:4rem}.pl6-l{padding-left:8rem}.pl7-l{padding-left:16rem}.pr0-l{padding-right:0}.pr1-l{padding-right:.25rem}.pr2-l{padding-right:.5rem}.pr3-l{padding-right:1rem}.pr4-l{padding-right:2rem}.pr5-l{padding-right:4rem}.pr6-l{padding-right:8rem}.pr7-l{padding-right:16rem}.pb0-l{padding-bottom:0}.pb1-l{padding-bottom:.25rem}.pb2-l{padding-bottom:.5rem}.pb3-l{padding-bottom:1rem}.pb4-l{padding-bottom:2rem}.pb5-l{padding-bottom:4rem}.pb6-l{padding-bottom:8rem}.pb7-l{padding-bottom:16rem}.pt0-l{padding-top:0}.pt1-l{padding-top:.25rem}.pt2-l{padding-top:.5rem}.pt3-l{padding-top:1rem}.pt4-l{padding-top:2rem}.pt5-l{padding-top:4rem}.pt6-l{padding-top:8rem}.pt7-l{padding-top:16rem}.pv0-l{padding-top:0;padding-bottom:0}.pv1-l{padding-top:.25rem;padding-bottom:.25rem}.pv2-l{padding-top:.5rem;padding-bottom:.5rem}.pv3-l{padding-top:1rem;padding-bottom:1rem}.pv4-l{padding-top:2rem;padding-bottom:2rem}.pv5-l{padding-top:4rem;padding-bottom:4rem}.pv6-l{padding-top:8rem;padding-bottom:8rem}.pv7-l{padding-top:16rem;padding-bottom:16rem}.ph0-l{padding-left:0;padding-right:0}.ph1-l{padding-left:.25rem;padding-right:.25rem}.ph2-l{padding-left:.5rem;padding-right:.5rem}.ph3-l{padding-left:1rem;padding-right:1rem}.ph4-l{padding-left:2rem;padding-right:2rem}.ph5-l{padding-left:4rem;padding-right:4rem}.ph6-l{padding-left:8rem;padding-right:8rem}.ph7-l{padding-left:16rem;padding-right:16rem}.ma0-l{margin:0}.ma1-l{margin:.25rem}.ma2-l{margin:.5rem}.ma3-l{margin:1rem}.ma4-l{margin:2rem}.ma5-l{margin:4rem}.ma6-l{margin:8rem}.ma7-l{margin:16rem}.ml0-l{margin-left:0}.ml1-l{margin-left:.25rem}.ml2-l{margin-left:.5rem}.ml3-l{margin-left:1rem}.ml4-l{margin-left:2rem}.ml5-l{margin-left:4rem}.ml6-l{margin-left:8rem}.ml7-l{margin-left:16rem}.mr0-l{margin-right:0}.mr1-l{margin-right:.25rem}.mr2-l{margin-right:.5rem}.mr3-l{margin-right:1rem}.mr4-l{margin-right:2rem}.mr5-l{margin-right:4rem}.mr6-l{margin-right:8rem}.mr7-l{margin-right:16rem}.mb0-l{margin-bottom:0}.mb1-l{margin-bottom:.25rem}.mb2-l{margin-bottom:.5rem}.mb3-l{margin-bottom:1rem}.mb4-l{margin-bottom:2rem}.mb5-l{margin-bottom:4rem}.mb6-l{margin-bottom:8rem}.mb7-l{margin-bottom:16rem}.mt0-l{margin-top:0}.mt1-l{margin-top:.25rem}.mt2-l{margin-top:.5rem}.mt3-l{margin-top:1rem}.mt4-l{margin-top:2rem}.mt5-l{margin-top:4rem}.mt6-l{margin-top:8rem}.mt7-l{margin-top:16rem}.mv0-l{margin-top:0;margin-bottom:0}.mv1-l{margin-top:.25rem;margin-bottom:.25rem}.mv2-l{margin-top:.5rem;margin-bottom:.5rem}.mv3-l{margin-top:1rem;margin-bottom:1rem}.mv4-l{margin-top:2rem;margin-bottom:2rem}.mv5-l{margin-top:4rem;margin-bottom:4rem}.mv6-l{margin-top:8rem;margin-bottom:8rem}.mv7-l{margin-top:16rem;margin-bottom:16rem}.mh0-l{margin-left:0;margin-right:0}.mh1-l{margin-left:.25rem;margin-right:.25rem}.mh2-l{margin-left:.5rem;margin-right:.5rem}.mh3-l{margin-left:1rem;margin-right:1rem}.mh4-l{margin-left:2rem;margin-right:2rem}.mh5-l{margin-left:4rem;margin-right:4rem}.mh6-l{margin-left:8rem;margin-right:8rem}.mh7-l{margin-left:16rem;margin-right:16rem}.na1-l{margin:-.25rem}.na2-l{margin:-.5rem}.na3-l{margin:-1rem}.na4-l{margin:-2rem}.na5-l{margin:-4rem}.na6-l{margin:-8rem}.na7-l{margin:-16rem}.nl1-l{margin-left:-.25rem}.nl2-l{margin-left:-.5rem}.nl3-l{margin-left:-1rem}.nl4-l{margin-left:-2rem}.nl5-l{margin-left:-4rem}.nl6-l{margin-left:-8rem}.nl7-l{margin-left:-16rem}.nr1-l{margin-right:-.25rem}.nr2-l{margin-right:-.5rem}.nr3-l{margin-right:-1rem}.nr4-l{margin-right:-2rem}.nr5-l{margin-right:-4rem}.nr6-l{margin-right:-8rem}.nr7-l{margin-right:-16rem}.nb1-l{margin-bottom:-.25rem}.nb2-l{margin-bottom:-.5rem}.nb3-l{margin-bottom:-1rem}.nb4-l{margin-bottom:-2rem}.nb5-l{margin-bottom:-4rem}.nb6-l{margin-bottom:-8rem}.nb7-l{margin-bottom:-16rem}.nt1-l{margin-top:-.25rem}.nt2-l{margin-top:-.5rem}.nt3-l{margin-top:-1rem}.nt4-l{margin-top:-2rem}.nt5-l{margin-top:-4rem}.nt6-l{margin-top:-8rem}.nt7-l{margin-top:-16rem}.strike-l{text-decoration:line-through}.underline-l{text-decoration:underline}.no-underline-l{text-decoration:none}.tl-l{text-align:left}.tr-l{text-align:right}.tc-l{text-align:center}.tj-l{text-align:justify}.ttc-l{text-transform:capitalize}.ttl-l{text-transform:lowercase}.ttu-l{text-transform:uppercase}.ttn-l{text-transform:none}.f-6-l,.f-headline-l{font-size:6rem}.f-5-l,.f-subheadline-l{font-size:5rem}.f1-l{font-size:3rem}.f2-l{font-size:2.25rem}.f3-l{font-size:1.5rem}.f4-l{font-size:1.25rem}.f5-l{font-size:1rem}.f6-l{font-size:.875rem}.f7-l{font-size:.75rem}.measure-l{max-width:30em}.measure-wide-l{max-width:34em}.measure-narrow-l{max-width:20em}.indent-l{text-indent:1em;margin-top:0;margin-bottom:0}.small-caps-l{font-variant:small-caps}.truncate-l{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.center-l{margin-left:auto}.center-l,.mr-auto-l{margin-right:auto}.ml-auto-l{margin-left:auto}.clip-l{position:fixed!important;_position:absolute!important;clip:rect(1px 1px 1px 1px);clip:rect(1px,1px,1px,1px)}.ws-normal-l{white-space:normal}.nowrap-l{white-space:nowrap}.pre-l{white-space:pre}.v-base-l{vertical-align:baseline}.v-mid-l{vertical-align:middle}.v-top-l{vertical-align:top}.v-btm-l{vertical-align:bottom}} ================================================ FILE: documentation/authentication.md ================================================ # Authentication & Authorization. ASP.NET Core has built-in support for authentication and authorization. Falco includes some prebuilt, configurable handlers for common scenarios. > Review the [docs](https://docs.microsoft.com/en-us/aspnet/core/security/authentication) for specific implementation details. ## Secure Resources ### Allow only authenticated access ```fsharp open Falco let authScheme = "some.secure.scheme" let secureResourceHandler : HttpHandler = let handleAuth : HttpHandler = Response.ofPlainText "hello authenticated user" Request.ifAuthenticated authScheme handleAuth ``` ### Allow only non-authenticated access ```fsharp open Falco let anonResourceOnlyHandler : HttpHandler = let handleAnon : HttpHandler = Response.ofPlainText "hello anonymous" Request.ifNotAuthenticated authScheme handleAnon ``` ### Allow only authenticated access when in certain role(s) ```fsharp open Falco let secureResourceHandler : HttpHandler = let handleAuthInRole : HttpHandler = Response.ofPlainText "hello admin" let rolesAllowed = [ "Admin" ] Request.ifAuthenticatedInRole authScheme rolesAllowed handleAuthInRole ``` ### Allow only authenticated acces with a certain scope ```fsharp open Falco let secureResourceHandler : HttpHandler = let handleAuthHasScope : HttpHandler = Response.ofPlainText "user1, user2, user3" let issuer = "https://oauth2issuer.com" let scope = "read:users" Request.ifAuthenticatedWithScope authScheme issuer scope handleAuthHasScope ``` ### Terminate authenticated session ```fsharp open Falco let logOut : HttpHandler = let authScheme = "..." let redirectTo = "/login" Response.signOutAndRedirect authScheme redirectTo ``` [Next: Host Configuration](host-configuration.md) ================================================ FILE: documentation/cross-site-request-forgery.md ================================================ # Cross-site Scripting (XSS) Attacks Cross-site scripting attacks are extremely common since they are quite simple to carry out. Fortunately, protecting against them is as easy as performing them. The [Microsoft.AspNetCore.Antiforgery](https://docs.microsoft.com/en-us/aspnet/core/security/anti-request-forgery) package provides the required utilities to easily protect yourself against such attacks. ## Activating Antiforgery Protection To use the Falco Xsrf helpers, ensure that the `Antiforgery` service has been [registered](https://learn.microsoft.com/en-us/aspnet/core/security/anti-request-forgery). ```fsharp open Falco open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection // ^-- this import enables antiforgery activation let endpoints = [ // endpoints... ] let bldr = WebApplication.CreateBuilder() bldr.Services .AddAntiforgery() let wapp = WebApplication.Create() wapp.UseAntiforgery() // ^-- activate Antiforgery before routing .UseRouting() .UseFalco(endpoints) .Run() ``` ## Falco XSRF Support Falco provides a few handlers via `Falco.Security.Xsrf`: ```fsharp open Falco.Markup open Falco.Security let formView token = _html [] [ _body [] [ _form [ _methodPost_ ] [ // using the CSRF HTML helper, recommended to include as first // form element Xsrf.antiforgeryInput token _control "first_name" [] [ _text "First Name" ] _control "first_name" [] [ _text "First Name" ] _input [ _typeSubmit_ ] ] ] ] // A handler that demonstrates obtaining a // CSRF token and applying it to a view let csrfViewHandler : HttpHandler = Response.ofHtmlCsrf formView // A handler that demonstrates validating // the request's CSRF token let mapFormSecureHandler : HttpHandler = let mapPerson (form : FormData) = { FirstName = form?first_name.AsString() LastName = form?last_name.AsString } let handleInvalid : HttpHandler = Response.withStatusCode 400 >> Response.ofEmpty Request.mapFormSecure mapPerson Response.ofJson handleInvalid ``` [Next: Authentication](authentication.md) ================================================ FILE: documentation/deployment.md ================================================ # Deployment One of the key features of Falco is that it contains little to no "magic" (i.e., no hidden reflection or dynamic code). This means that Falco is both [trimmable](https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/trim-self-contained) and [AOT](https://learn.microsoft.com/en-us/dotnet/core/deploying/native-aot) compatible out of the box. This means that you can deploy your Falco application as a self-contained executable, or as a native AOT executable, with no additional configuration. A huge benefit of this is that you can deploy your Falco application to any environment, without having to worry about the underlying runtime or dependencies. > Important! If you're in a __scale-to-zero__ hosting environment consider using a [ReadyToRun](https://learn.microsoft.com/en-us/dotnet/core/deploying/ready-to-run) deployment. This will ensure that your application will experience faster cold start times. ## Self-contained deployments It is highly recommended to deploy your Falco application as a self-contained executable. This means that the .NET runtime and all dependencies are included in the deployment package, so you don't have to worry about the target environment having the correct version of .NET installed. This will result in a slightly larger deployment package, but it will ensure that your application runs correctly in any environment. The larger binary size can also be offset by using trim. Below is an example [Directory.Build.props] that will help enable the non-AOT features. These properties can also be added to you fsproj file. ```xml true true true Link true true ``` ## Native AOT deployments Publishing your app as Native AOT produces an app that's self-contained and that has been ahead-of-time (AOT) compiled to native code. Native AOT apps have faster startup time and smaller memory footprints. These apps can run on machines that don't have the .NET runtime installed. Since AOT deployments require trimming, and are single file by nature the only required msbuild property is: ```xml true ``` [Next: Example - Hello World](example-hello-world.md) ================================================ FILE: documentation/example-basic-rest-api.md ================================================ # Example - Basic REST API This example demonstrates how to create a basic REST API using Falco. The API will allow users to perform CRUD (Create, Read, Update, Delete) operations on a simple resource, users in this case. The API will be built using the following components, in addition to the Falco framework: - [System.Data.SQLite](https://www.nuget.org/packages/System.Data.SQLite/), which provides SQLite support, built and maintained by the SQLite developers. - [Donald](https://www.nuget.org/packages/Donald/) which simplifies database access, built and maintained by the Falco developers. > For simplicity, we'll stick to sychronous database access in this example. However, you can easily adapt the code to use asynchronous database access if needed. Specific to SQLite, in many cases it is better to use synchronous access, and let SQLite handle serialization for you. The code for this example can be found [here](https://github.com/FalcoFramework/Falco/tree/master/examples/BasicRestApi). ## Creating the Application Manually ```shell > dotnet new falco -o BasicRestApiApp > cd BasicRestApiApp > dotnet add package System.Data.SQLite > dotnet add package Donald ``` ## Overview The API will consist of four endpoints: - `GET /users`: Retrieve all users. - `GET /users/{username}`: Retrieve a user by username. - `POST /users`: Create a new user. - `DELETE /users/{username}`: Delete a user by username. Users will be stored in a SQLite database, and the API will use Donald to interact with the database. Our user model will be a simple record type with two properties: `Username` and `Full Name`. ```fsharp type User = { Username : string FullName : string } ``` It's also valueable to have a concrete type to represent API errors. This will be used to return error messages in a consistent format. ```fsharp type Error = { Code : string Message : string } ``` ## Data Access To interact with the SQLite database, we'll create some abstractions for establishing new connections and performing database operations. A connection factory is a useful concept to avoid passing around connection strings. It allows us to create new connections without needing to know the details of how they are created. ```fsharp type IDbConnectionFactory = abstract member Create : unit -> IDbConnection ``` We'll also define an interface for performing list, create, read and delete operations against a set of entities. ```fsharp type IStore<'TKey, 'TItem> = abstract member List : unit -> 'TItem list abstract member Create : 'TItem -> Result abstract member Read : 'TKey -> 'TItem option abstract member Delete : 'TKey -> Result ``` The `IStore` interface is generic, allowing us to use it with any type of entity. In our case, we'll create a concrete implementation for the `User` entity. ## Implementing the Store ## Error Responses The API will return error responses in a consistent format. To do this, we'll create three functions for the common error cases: `notFound`, `badRequest`, and `serverException`. ```fsharp module ErrorResponse = let badRequest error : HttpHandler = Response.withStatusCode 400 >> Response.ofJson error let notFound : HttpHandler = Response.withStatusCode 404 >> Response.ofJson { Code = "404"; Message = "Not Found" } let serverException : HttpHandler = Response.withStatusCode 500 >> Response.ofJson { Code = "500"; Message = "Server Error" } ``` Here you can see our error type in action, which is used to return a JSON response with the error code and message. The signature of the `badRequest` function is a bit different, as it takes an error object as input and returns a `HttpHandler`. The reason for this is that we intend to invoke this function from within our handlers, and we want to be able to pass the error object directly to it. ## Defining the Endpoints It can be very useful to define values for the endpoints we want to expose. This allows us to easily change the endpoint paths in one place if needed, and also provides intellisense support when using the endpoints in our code. ```fsharp module Route = let userIndex = "/users" let userAdd = "/users" let userView = "/users/{username}" let userRemove = "/users/{username}" ``` Next, let's implement the handlers for each of the endpoints. First, we'll implement the `GET /users` endpoint, which retrieves all users from the database. ```fsharp module UserEndpoint = let index : HttpHandler = fun ctx -> let userStore = ctx.Plug>() let allUsers = userStore.List() Response.ofJson allUsers ctx ``` The `index` function retrieves the `IStore` instance from the dependency container and calls the `List` method to get all users. The result is then returned as a JSON response. Next, we'll implement the `POST /users` endpoint, which creates a new user. ```fsharp module UserEndpoint = // ... index handler ... let add : HttpHandler = fun ctx -> task { let userStore = ctx.Plug>() let! userJson = Request.getJson ctx let userAddResponse = match userStore.Create(userJson) with | Ok result -> Response.ofJson result ctx | Error error -> ErrorResponse.badRequest error ctx return! userAddResponse } ``` The `add` function retrieves the `IStore` instance from the dependency container and calls the `Create` method to add a new user. The result is then returned as a JSON response. If the user creation fails, we return a bad request error. Next, we'll implement the `GET /users/{username}` endpoint, which retrieves a user by username. ```fsharp module UserEndpoint = // ... index and add handlers ... let view : HttpHandler = fun ctx -> let userStore = ctx.Plug>() let route = Request.getRoute ctx let username = route?username.AsString() match userStore.Read(username) with | Some user -> Response.ofJson user ctx | None -> ErrorResponse.notFound ctx ``` The `view` function retrieves the `IStore` instance from the dependency container and calls the `Read` method to get a user by username. If the user is found, it is returned as a JSON response. If not, we return a not found error. Finally, we'll implement the `DELETE /users/{username}` endpoint, which deletes a user by username. ```fsharp module UserEndpoint = // ... index, add and view handlers ... let remove : HttpHandler = fun ctx -> let userStore = ctx.Plug>() let route = Request.getRoute ctx let username = route?username.AsString() match userStore.Delete(username) with | Ok result -> Response.ofJson result ctx | Error error -> ErrorResponse.badRequest error ctx ``` The `remove` function retrieves the `IStore` instance from the dependency container and calls the `Delete` method to remove a user by username. The result is then returned as a JSON response. If the user deletion fails, we return a bad request error. ## Configuring the Application Conventionally, you'll configure your database outside of your application scope. For the purpose of this example, we'll define and initialize the database during startup. ```fsharp module Program = [] let main args = let dbConnectionFactory = { new IDbConnectionFactory with member _.Create() = new SQLiteConnection("Data Source=BasicRestApi.sqlite3") } let initializeDatabase (dbConnection : IDbConnectionFactory) = use conn = dbConnection.Create() conn |> Db.newCommand "CREATE TABLE IF NOT EXISTS user (username, full_name)" |> Db.exec initializeDatabase dbConnectionFactory // ... rest of the application setup ``` First we implement the `IDbConnectionFactory` interface, which creates a new SQLite connection. Then we define a `initializeDatabase` function, which creates the database and the user table if it doesn't exist. We encapsulate the database initialization in a function, so we can quickly dispose of the connection after use. Next, we need to register our database connection factory and the `IStore` implementation in the dependency container. ```fsharp module Program = [] let main args = // ... database initialization ... let bldr = WebApplication.CreateBuilder(args) bldr.Services .AddAntiforgery() .AddScoped(dbConnectionFactory) .AddScoped, UserStore>() |> ignore ``` Finally, we need to configure the application to use the defined endpoints. ```fsharp module Program = [] let main args = // ... database initialization & dependency registration ... let wapp = bldr.Build() let isDevelopment = wapp.Environment.EnvironmentName = "Development" wapp.UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage) .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorResponse.serverException) .UseRouting() .UseFalco(App.endpoints) .Run(ErrorResponse.notFound) 0 // Exit code ``` The `UseFalco` method is used to register the endpoints, and the `Run` method is used to handle requests that don't match any of the defined endpoints. ## Wrapping Up And there you have it! A simple REST API built with Falco, SQLite and Donald. This example demonstrates how to create a basic CRUD API, but you can easily extend it to include more complex functionality, such as authentication, validation, and more. [Next: Example - Open API](example-open-api.md) ================================================ FILE: documentation/example-dependency-injection.md ================================================ # Example - Dependency Injection An important and nuanced subject to discuss is dependency injection. There's a myriad of beliefs and approaches, all of which have their merit. In the case of Falco, you are living in the world of ASP.NET which has [built-in support](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection) for this. It works very well and you should use it. But make sure you follow through their [docs](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-8.0) on how it works and integrates with ASP.NET. Going back to our basic [Hello World](example-hello-world.md) app, let's add in an external dependency to demonstrate some of the basics of dependency injection in Falco. The code for this example can be found [here](https://github.com/FalcoFramework/Falco/tree/master/examples/DependencyInjection). ## Creating the Application Manually ```shell > dotnet new falco -o DependencyInjectionApp ``` ## Creating Abstraction The benefit of abstracting functionality is that it removes the coupling between your implementation and the calling code. You instead rely on an accepted definition of what something does. F# has excellent support for [object programming](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/classes). There might be an urge to avoid this type of approach because "ugh classes are gross". But suck it up buttercup, they are wickedly useful in many cases and a reminder that F# code doesn't have to adhere to some functional purism. In the case of our application, we're going to define an abstraction for greeting patrons. Then write a simple implementation. > This is a completely contrived example, created purely to demonstrate how to register and consume dependencies. ```fsharp type IGreeter = abstract member Greet : name : string -> string type FriendlyGreeter() = interface IGreeter with member _.Greet(name : string) = $"Hello {name} 😀" ``` Simple enough, we describe an `IGreeter` as having the ability to `Greet` in the form of receiving a name string and return a string message. Next we define an implementation that fulfills this interface in a friendly way. ## Registering the Dependency To provide runtime access to our greeter, we have to register the dependency in the container. The abstraction from ASP.NET for this is called `IServiceCollection`. You can register dependencies in a number of ways, but fundamental to all is the concept of [service lifetime](https://learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#service-lifetimes). It distills down to: - Transient = new for every container access - Scoped = new for every client request - Singleton = created at startup, or first container access Our greeter is both stateless and cheap to construct. So any of the lifetimes will suffice. But let's register it as a singleton. This time however, we'll create our web server in two stages, to gain access to the dependency container. ```fsharp let bldr = WebApplication.CreateBuilder() // <-- create a configurable web application builder bldr.Services .AddSingleton() // <-- register the greeter as singleton in the container |> ignore let wapp = bldr.Build() // <-- manifest our WebApplication let endpoints = [ mapGet "/{name?}" (fun r -> r?name.AsString("world")) (fun name ctx -> let greeter = ctx.Plug() // <-- access our dependency from the container let greeting = greeter.Greet(name) // <-- invoke our greeter.Greet(name) method Response.ofPlainText greeting ctx) ] wapp.UseRouting() .UseFalco(endpoints) .Run() ``` Following through you can see the web server being created in two phases. The first to establish the context (i.e., logging, server configuration and dependencies). Second, freezing the final state and creating a configurable web application. Within the handler you can see the interaction with the dependency container using `ctx.Plug()`. This code tells the container to return the implementation it has registered for that abstraction. In our case `FriendlyGreeter`. ## Wrapping Up Now that we're finished introducing dependency injection, let's move on to a real world example by integrating with an external view engine. [Next: Example - External View Engine](example-external-view-engine.md) ================================================ FILE: documentation/example-external-view-engine.md ================================================ # Example - External View Engine Falco comes packaged with a [built-in view engine](markup.md). But if you'd prefer to write your own templates, or use an external template engine, that is entirely possible as well. In this example we'll do some basic page rendering by integrating with [scriban](https://github.com/scriban/scriban). An amazing template engine by [xoofx](https://github.com/xoofx). The code for this example can be found [here](https://github.com/FalcoFramework/Falco/tree/master/examples/ExternalViewEngine). ## Creating the Application Manually ```shell > dotnet new falco -o ExternalViewEngineApp > cd ExternalViewEngineApp > dotnet add package Scriban ``` ## Implementing a Template Engine There are a number of ways we could achieve this functionality. But in sticking with our previous examples, we'll create an interface. To keep things simple we'll use inline string literals for templates and perform rendering synchronously. ```fsharp open Scriban type ITemplate = abstract member Render : template: string * model: obj -> string type ScribanTemplate() = interface ITemplate with member _.Render(template, model) = let tmpl = Template.Parse template tmpl.Render(model) ``` We define an interface `ITemplate` which describes template rendering as a function that receives a template string literal and a model, producing a string literal. Then we implement this interface definition using Scriban. ## Rendering Pages To use our Scriban template engine we'll need to request it from the dependency container, then pass it our template literal and model. > See [dependency injection](example-dependency-injection.md) for further explanation. Since rendering more than one page is the goal, we'll create a shared `renderPage` function to do the dirty work for us. ```fsharp open Falco module Pages = let private renderPage pageTitle template viewModel : HttpHandler = fun ctx -> let templateService = ctx.Plug() // <-- obtain our template service from the dependency container let pageContent = templateService.Render(template, viewModel) // <-- render our template with the provided view model as string literal let htmlTemplate = """ {{title}} {{content}} """ // ^ these triple quoted strings auto-escape characters like double quotes for us // very practical for things like HTML let html = templateService.Render(htmlTemplate, {| Title = pageTitle; Content = pageContent |}) Response.ofHtmlString html ctx // <-- return template literal as "text/html; charset=utf-8" response ``` In this function we obtain the instance of our template engine, and immediately render the user-provided template and model. Next, we define a local template literal to serve as our layout. Assigning two simple inputs, `{{title}}` and `{{content}}`. Then we render the layout template using our template engine and an anonymous object literal `{| Title = pageTitle; Content = pageContent |}`, responding with the result of this as `text/html`. To render pages, we simply need to create a localized template literal, and feed it into our `renderPage` function. Below we define a home and 404 page. ```fsharp let homepage : HttpHandler = fun ctx -> let query = Request.getQuery ctx // <-- obtain access to strongly-typed representation of the query string let viewModel = {| Name = query?name.AsStringNonEmpty("World") |} // <-- access 'name' from query, or default to 'World' let template = """

Hello {{ name }}!

""" renderPage $"Hello {viewModel.Name}" template viewModel ctx let notFound : HttpHandler = let template = """

Page not found

""" renderPage "Page Not Found" template {||} ``` ## Registering the Template Engine Since our Scriban template engine is stateless and dependency-free, we can use the generic extension method to register it as a singleton. > Note: `Transient` and `Scoped` lifetimes would also work here. ``` open Falco open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection [] let main args = let bldr = WebApplication.CreateBuilder(args) bldr.Services .AddSingleton() // <-- register ITemplates implementation as a dependency |> ignore let endpoints = [ get "/" Pages.homepage ] let wapp = bldr.Build() wapp.UseRouting() .UseFalco(endpoints) .UseFalcoNotFound(Pages.notFound) .Run() 0 // Exit code ``` ## Wrapping Up This example demonstrates how to effectively integrate an external view engine into your Falco application. By defining a simple interface, implementing it with Scriban and adding it to the dependency container, we can render HTML pages dynamically based on user input. [Next: Example - Basic REST API](example-basic-rest-api.md) ================================================ FILE: documentation/example-hello-world-mvc.md ================================================ # Example - Hello World MVC Let's take our basic [Hello World](example-hello-world.md) to the next level. This means we're going to dial up the complexity a little bit. But we'll do this using the well recognized MVC pattern. We'll contain the app to a single file to make "landscaping" the pattern more straight-forward. The code for this example can be found [here](https://github.com/FalcoFramework/Falco/tree/master/examples/HelloWorldMvc). ## Creating the Application Manually ```shell > dotnet new falco -o HelloWorldMvcApp ``` ## Model Since this app has no persistence, the model is somewhat boring. But included here to demonstrate the concept. We define two simple record types. One to contain the patron name, the other to contain a `string` message. ```fsharp module Model = type NameGreeting = { Name : string } type Greeting = { Message : string } ``` ## Routing As the project scales, it is generally helpful to have static references to your URLs and/or URL generating functions for dynamic resources. [Routing](routing.md) begins with a route template, so it's only natural to define those first. ```fsharp module Route = let index = "/" let greetPlainText = "/greet/text/{name}" let greetJson = "/greet/json/{name}" let greetHtml = "/greet/html/{name}" ``` Here you can see we define one static route, and 3 dynamic route templates. We can provide URL generation from these dynamic route templates quite easily with some simple functions. ```fsharp module Url = let greetPlainText name = Route.greetPlainText.Replace("{name}", name) let greetJson name = Route.greetJson.Replace("{name}", name) let greetHtml name = Route.greetHtml.Replace("{name}", name) ``` These 3 functions take a string input called `name` and plug it into the `{name}` placeholder in the route template. This gives us a nice little typed API for creating our application URLs. ## View Falco comes packaged with a [lovely little HTML DSL](https://github.com/FalcoFramework/Falco.Markup/). It can produce any form of angle-markup, and does so very [efficiently](https://github.com/FalcoFramework/Falco.Markup/?tab=readme-ov-file#performance). The main benefit is that our views are _pure_ F#, compile-time checked and live alongside the rest of our code. First we define a shared HTML5 `layout` function, that references our project `style.css`. Next, we define a module to contain the views for our greetings. > You'll notice the `style.css` file resides in a folder called `wwwroot`. This is an [ASP.NET convention](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/static-files) which we'll enable later when we [build the web server](#web-server). ```fsharp module View = open Model let layout content = Templates.html5 "en" [ _link [ _href_ "/style.css"; _rel_ "stylesheet" ] ] content module GreetingView = /// HTML view for /greet/html let detail greeting = layout [ _h1' $"Hello {greeting.Name} from /html" _hr [] _p' "Greet other ways:" _nav [] [ _a [ _href_ (Url.greetPlainText greeting.Name) ] [ _text "Greet in text"] _text " | " _a [ _href_ (Url.greetJson greeting.Name) ] [ _text "Greet in JSON " ] ] ] ``` The markup code is fairly self-explanatory. But essentially: - `Elem` produces HTML elements. - `Attr` produces HTML element attributes. - `Text` produces HTML text nodes. Each of these modules matches (or tries to) the full HTML spec. You'll also notice two of our URL generators at work. ## Errors We'll define a couple static error pages to help prettify our error output. ```fsharp module Controller = open Model open View module ErrorController = let notFound : HttpHandler = Response.withStatusCode 404 >> Response.ofHtml (View.layout [ _h1' "Not Found" ]) let serverException : HttpHandler = Response.withStatusCode 500 >> Response.ofHtml (View.layout [ _h1' "Server Error" ]) let endpoints = [ get "/error/not-found" notFound get "/error/server-exception" serverException ] ``` Here we see the [`HttpResponseModifier`](repsonse.md#response-modifiers) at play, which set the status code before buffering out the HTML response. We'll reference these pages later when be [build the web server](#web-server). We'll also add explicit endpoints for these, so they can be accessed directly or redirected to. ## Controller Our controller will be responsible for four actions, as defined in our [route](#routing) module. We define four handlers, one parameterless greeting and three others which output the user provided "name" in different ways: plain text, JSON and HTML. ```fsharp module Controller = open Model open View module ErrorController = // ... module GreetingController = let index = Response.ofPlainText "Hello world" let plainTextDetail name = Response.ofPlainText $"Hello {name}" let jsonDetail name = let message = { Message = $"Hello {name} from /json" } Response.ofJson message let htmlDetail name = { Name = name } |> GreetingView.detail |> Response.ofHtml let endpoints = let mapRoute (r : RequestData) = r?name.AsString() [ mapGet Route.index mapRoute index mapGet Route.greetPlainText mapRoute plainTextDetail mapGet Route.greetJson mapRoute jsonDetail mapGet Route.greetHtml mapRoute index ] ``` You'll notice that this controller defines its own `endpoints` too. This associates a route to a handler when passed into Falco (we'll do this later). Defining this within the controller is personal preference. But considering controller actions usually operate against a common URL pattern, it allows a private, reusable route mapping to exist (see `mapRoute`). ## Web Server This is a great opportunity to demonstrate further how to configure a more complex web server than we saw in the basic hello world example. To do that, we'll define an explicit entry point function which gives us access to the command line argument. By then forwarding these into the web application, we gain further [configurability](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration#command-line). You'll notice the application contains a file called `appsettings.json`, this is another [ASP.NET convention](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration#default-application-configuration-sources) that provides fully-featured and extensible configuration functionality. Next we define an explicit collection of endpoints, which gets passed into the `.UseFalco(endpoints)` extension method. In this example, we examine the environment name to create an "is development" toggle. We use this to determine the extensiveness of our error output. You'll notice we use our exception page from above when an exception occurs when not in development mode. Otherwise, we show a developer-friendly error page. Next we activate static file support, via the default web root of `wwwroot`. We end off by registering a terminal handler, which functions as our "not found" response. ```fsharp module Program = open Controller let endpoints = ErrorController.endpoints @ GreetingController.endpoints /// By defining an explicit entry point, we gain access to the command line /// arguments which when passed into Falco are used as the creation arguments /// for the internal WebApplicationBuilder. [] let main args = let wapp = WebApplication.Create(args) let isDevelopment = wapp.Environment.EnvironmentName = "Development" wapp.UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage) .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorPage.serverException) .Use(StaticFileExtensions.UseStaticFiles) .UseFalco(endpoints) .UseFalcoNotFound(ErrorPage.notFound) .Run() 0 ``` ## Wrapping Up This example was a leap ahead from our basic hello world. But having followed this, you know understand many of the patterns you'll need to know to build end-to-end server applications with Falco. Unsurprisingly, the entire program fits inside 118 LOC. One of the magnificent benefits of writing code in F#. [Next: Example - Dependency Injection](example-dependency-injection.md) ================================================ FILE: documentation/example-hello-world.md ================================================ # Example - Hello World The goal of this program is to demonstrate the absolute bare bones hello world application, so that we can focus on the key elements when initiating a new web application. The code for this example can be found [here](https://github.com/FalcoFramework/Falco/tree/master/examples/HelloWorld). ## Creating the Application Manually ```shell > dotnet new falco -o HelloWorldApp ``` ## Code Overview ```fsharp open Falco open Falco.Routing open Microsoft.AspNetCore.Builder // ^-- this import adds many useful extensions let wapp = WebApplication.Create() wapp.UseRouting() .UseFalco([ // ^-- activate Falco endpoint source get "/" (Response.ofPlainText "Hello World!") // ^-- associate GET / to plain text HttpHandler ]) .Run(Response.ofPlainText "Not found") // ^-- activate Falco endpoint source ``` First, we open the required namespaces. `Falco` bring into scope the ability to activate the library and some other extension methods to make the fluent API more user-friendly. `Microsoft.AspNetCore.Builder` enables us to create web applications in a number of ways, we're using `WebApplication.Create()` above. It also adds many other useful extension methods, that you'll see later. After creating the web application, we: - Activate Falco using `wapp.UseFalco()`. This enables us to create endpoints. - Register `GET /` endpoint to a handler which responds with "hello world". - Run the app. [Next: Example - Hello World MVC](example-hello-world-mvc.md) ================================================ FILE: documentation/example-htmx.md ================================================ # Example - HTMX [Falco.Htmx](https://github.com/FalcoFramework/Falco.Htmx) brings type-safe [htmx](https://htmx.org/) support to [Falco](https://github.com/FalcoFramework/Falco). It provides a complete mapping of all attributes, typed request data and ready-made response modifiers. In this example, we'll demonstrate some of the more common htmx attributes and how to use them with Falco. At this point, we'll assume you have reviewed the docs, other examples and understand the basics of Falco. We don't be covering any of the basics in the code review. The code for this example can be found [here](https://github.com/FalcoFramework/Falco/tree/master/examples/Htmx). ## Creating the Application Manually ```shell > dotnet new falco -o HtmxApp > cd HtmxApp > dotnet add package Falco.Htmx ``` ## Layout First we'll define a simple layout and enable htmx by including the script. Notice the strongly typed reference, `HtmxScript.cdnSrc`, which is provided by Falco.Htmx and resolves to the official CDN URL. ```fsharp module View = let template content = _html [ _lang_ "en" ] [ _head [] [ _script [ _src_ HtmxScript.cdnSrc ] [] ] _body [] content ] ``` With our layout defined, we can create a view to represent our starting state. ```fsharp module View = // Layout ... let clickAndSwap = template [ _h1' "Example: Click & Swap" _div [ _id_ "content" ] [ _button [ _id_ "clicker" Hx.get "/click" Hx.swapOuterHtml ] [ _text "Click Me" ] ] ] ``` This view contains a button that, when clicked, will send a GET request to the `/click` endpoint. The response from that request will replace the button with the response from the server. ## Components A nice convention when working with Falco.Markup is to create a `Components` module within your `View` module. We'll define one component here. All of the htmx attributes and properties are mapped within the `Hx` module. Wherever a limited scope of options exist, strongly typed references are provided. For example, `Hx.swapInnerHtml` is a strongly typed reference to the `hx-swap` attribute with the value `innerHTML`. This is a great way to avoid typos and ensure that your code is type-safe. ```fsharp module View = // Layout & view ... module Components = let resetter = _div [ _id_ "resetter" ] [ _h2' "Way to go! You clicked it!" _br [] _button [ Hx.get "/reset" Hx.swapOuterHtml Hx.targetCss "#resetter" ] [ _text "Reset" ] ] ``` The `resetter` component is a simple button that will send a GET request to the server when clicked. The response will replace the entire `div` with the ID of `resetter` with the response from the server. ## Handlers Next we define a couple basic handlers to handle the requests for the original document and ajax requests. ```fsharp module App = let handleIndex : HttpHandler = Response.ofHtml View.clickAndSwap let handleClick : HttpHandler = Response.ofHtml View.Components.resetter let handleReset : HttpHandler = Response.ofFragment "clicker" View.clickAndSwap ``` The `handleIndex` handler is returning our full click-and-swap view, containing the clicker button. Clicking it triggers a request to the `handleClick` handler, which returns the resetter component. Clicking the reset button triggers a request to the `handleReset` handler, which returns the original clicker button as a [template fragment], extracted from the same view as the original state. ## Web Server To finish things off, we'll map our handlers to the expected routes and initialize the web server. ```fsharp let wapp = WebApplication.Create() let endpoints = [ get "/" App.handleIndex get "/click" App.handleClick get "/reset" App.handleReset ] wapp.UseRouting() .UseFalco(endpoints) .Run() ``` ## Wrapping Up That's it! You now have a simple web application that uses htmx to swap out components on the page without a full page reload. This is just the beginning of what you can do with htmx and Falco. You can use the same principles to create more complex interactions and components. For more information about the htmx integration, check out the [Falco.Htmx](https://github.com/FalcoFramework/Falco.Htmx) repository. It contains a full list of all the attributes and properties that are available, as well as examples of how to use them. [Go back to docs home](/docs) ================================================ FILE: documentation/example-open-api.md ================================================ # Example - Open API Open API is a specification for defining APIs in a machine-readable format. It allows developers to describe the structure of their APIs, including endpoints, request/response formats, and authentication methods. [Falco.OpenAPI](https://github.com/FalcoFramework/Falco.OpenAPI) is a library for generating OpenAPI documentation for Falco applications. It provides a set of combinators for annotating Falco routes with OpenAPI metadata, which can be used to generate OpenAPI documentation. We'll dial back the complexity a bit from the [Basic REST API](example-basic-rest-api.md) example and create a simple "fortune teller" Falco application that serves OpenAPI documentation. The code for this example can be found [here](https://github.com/FalcoFramework/Falco/tree/master/examples/OpenApi). ## Creating the Application Manually ```shell > dotnet new falco -o OpenApiApi > cd OpenApiApp > dotnet add package Falco.OpenApi ``` ## Fortunes Our fortune teller will return fortune for the name of the person specified. To model this, we'll create two simple record types. ```fsharp type FortuneInput = { Name : string } type Fortune = { Description : string } ``` For simplicity, we'll use a static member to return a fortune. In a real application, you would likely retrieve this from a database or an external service. ```fsharp module Fortune = let create age input = match age with | Some age when age > 0 -> { Description = $"{input.Name}, you will experience great success when you are {age + 3}." } | _ -> { Description = $"{input.Name}, your future is unclear." } ``` ## OpenAPI Annotations Next, we'll annotate our route with OpenAPI metadata. This is done using the `OpenApi` module from the `Falco.OpenAPI` package. Below is the startup code for our fortune teller application. We'll dissect it after the code block, and then add the OpenAPI annotations. ```fsharp [] let main args = let bldr = WebApplication.CreateBuilder(args) bldr.Services .AddFalcoOpenApi() // ^-- add OpenAPI services .AddSwaggerGen() // ^-- add Swagger services |> ignore let wapp = bldr.Build() wapp.UseHttpsRedirection() .UseSwagger() .UseSwaggerUI() |> ignore let endpoints = [ mapPost "/fortune" (fun r -> r?age.AsIntOption()) (fun ageOpt -> Request.mapJson (Fortune.create ageOpt >> Response.ofJson)) // we'll add OpenAPI annotations here ] wapp.UseRouting() .UseFalco(endpoints) .Run() 0 ``` We've created a simple Falco application that listens for POST requests to the `/fortune` endpoint. The request body is expected to be a JSON object with a `name` property. The response will be a JSON object with a `description` property. Now, let's add the OpenAPI annotations to our route. ```fsharp [] let main args = // ... application setup code ... let endpoints = [ mapPost "/fortune" (fun r -> r?age.AsIntOption()) (fun ageOpt -> Request.mapJson (Fortune.create ageOpt >> Response.ofJson)) |> OpenApi.name "Fortune" |> OpenApi.summary "A mystic fortune teller" |> OpenApi.description "Get a glimpse into your future, if you dare." |> OpenApi.query [ { Type = typeof; Name = "Age"; Required = false } ] |> OpenApi.acceptsType typeof |> OpenApi.returnType typeof ] // ... application startup code ... 0 // Exit code ``` In the code above, we use the `OpenApi` module to annotate our route with metadata. Here's a breakdown of the annotations: - `OpenApi.name`: Sets the name of the operation. - `OpenApi.summary`: Provides a short summary of the operation. - `OpenApi.description`: Provides a detailed description of the operation. - `OpenApi.query`: Specifies the query parameters for the operation. In this case, we have an optional `age` parameter. - `OpenApi.acceptsType`: Specifies the expected request body type. In this case, we expect a JSON object that can be deserialized into a `FortuneInput` record. - `OpenApi.returnType`: Specifies the response type. In this case, we return a JSON object that can be serialized into a `Fortune` record. ## Wrapping Up That's it! You've successfully created a simple Falco application with OpenAPI documentation. You can now use the generated OpenAPI specification to generate client code, create API documentation, or integrate with other tools that support OpenAPI. [Next: Example - htmx](example-htmx.md) ================================================ FILE: documentation/get-started.md ================================================ # Getting Started ## Using `dotnet new` The easiest way to get started with Falco is by installing the `Falco.Template` package, which adds a new template to your `dotnet new` command line tool: ```shell > dotnet new install "Falco.Template::*" ``` Afterwards you can create a new Falco application by running: ```shell > dotnet new falco -o HelloWorldApp > cd HelloWorldApp > dotnet run ``` ## Manually installing Create a new F# web project: ```shell > dotnet new web -lang F# -o HelloWorldApp > cd HelloWorldApp ``` Install the nuget package: ```shell > dotnet add package Falco ``` Remove any `*.fs` files created automatically, create a new file named `Program.fs` and set the contents to the following: ```fsharp open Falco open Falco.Routing open Microsoft.AspNetCore.Builder // ^-- this import adds many useful extensions let endpoints = [ get "/" (Response.ofPlainText "Hello World!") // ^-- associate GET / to plain text HttpHandler ] let wapp = WebApplication.Create() wapp.UseRouting() .UseFalco(endpoints) // ^-- activate Falco endpoint source .Run(Response.ofPlainText "Not found") // ^-- run app and register terminal (i.e., not found) middleware ``` Run the application: ```shell > dotnet run ``` And there you have it, an industrial-strength [Hello World](https://github.com/FalcoFramework/Falco/tree/master/examples/HelloWorld) web app. Pretty sweet! [Next: Routing](routing.md) ================================================ FILE: documentation/host-configuration.md ================================================ # Host Configuration As your app becomes more complex, you'll inevitably need to reach for some additional host configuration. This is where the `Microsoft.AspNetCore.Builder` import comes in. This assembly contains many useful extensions for configuring the server (ex: static files, authentication, authorization etc.). Most of the extension methods have existed since the early days of ASP.NET Core and operate against `IApplicationBuilder`. But more recent version of ASP.NET Core have introduced a new `WebApplication` type that implements `IApplicationBuilder` and provides some additional functionality, notably endpoint configuration. This dichotomy makes pipelining next to impossible. In C# you don't feel the sting of this as much because of `void` returns. But in F# this results in an excess amount of `|> ignore` calls. Let's take the hero code from the [Getting Started](get-started.md) page and add the static file middleware to it: ```fsharp module Program open Falco open Falco.Routing open Microsoft.AspNetCore.Builder let wapp = WebApplication.Create() wapp.UseRouting() .UseDefaultFiles() // you might innocently think this is fine .UseStaticFiles() // and so is this // but uknowingly, the underlying type has changed .UseFalco([ get "/" (Response.ofPlainText "Hello World!") ]) .Run(Response.ofPlainText "Not found") // ^-- this is no longer starts up our application // one way to fix this: wapp.UseRouting() |> ignore wapp.UseDefaultFiles().UseStaticFiles() |> ignore wapp.UseFalco([ get "/" (Response.ofPlainText "Hello World!") ]) .Run(Response.ofPlainText "Not found") // but we can do better ``` To salve this, Falco comes with a several shims. The most important of these are `WebApplication.Use` and `WebApplication.UseIf` which allow you to compose a pipeline entirely driven by `WebApplication` while at the same time taking advantage of the existing ASP.NET Core extensions. ```fsharp wapp.UseRouting() .Use(fun (appl : IApplicationBuilder) -> appl.UseDefaultFiles() .UseStaticFiles()) .UseFalco([ get "/" (Response.ofPlainText "Hello World!") ]) .Run(Response.ofPlainText "Not found") ``` The optional, but recommended way to take advantage of these is to utilize the static methods that server as the underpinning to the various extension methods available. The code below will attempt to highlight this more clearly: ```fsharp // better yet wapp.UseRouting() .Use(DefaultFilesExtensions.UseDefaultFiles) .Use(StaticFileExtensions.UseStaticFiles) // ^-- most IApplicationBuilder extensions are available as static methods similar to this .UseFalco([ get "/" (Response.ofPlainText "Hello World!") ]) .Run(Response.ofPlainText "Not found") ``` Next, we can use the `UseIf` extension method to conditionally add middleware to the pipeline. This is useful for things like development exception pages, or other middleware that you only want in certain environments. ```fsharp let isDevelopment = wapp.Environment.EnvironmentName = "Development" wapp.UseRouting() .UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage) .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorPage.serverException) .Use(DefaultFilesExtensions.UseDefaultFiles) .Use(StaticFileExtensions.UseStaticFiles) .UseFalco([ get "/" (Response.ofPlainText "Hello World!") ]) .Run(Response.ofPlainText "Not found") ``` This is a great way to keep your code clean and readable, while still taking advantage of the powerful middleware pipeline that ASP.NET Core provides. [Next: Deployment](deployment.md) ================================================ FILE: documentation/markup.md ================================================ # Markup Falco.Markup is broken down into three primary modules, `Elem`, `Attr` and `Text`, which are used to generate elements, attributes and text nodes respectively. Each module contain a suite of functions mapping to the various element/attribute/node names. But can also be extended to create custom elements and attributes. Primary elements are broken down into two types, `ParentNode` or `SelfClosingNode`. `ParentNode` elements are those that can contain other elements. Represented as functions that receive two inputs: attributes and optionally elements. Each of the primary modules can be access using the name directly, or using the "underscore syntax" seen below. | Module | Syntax | |--------|--------| | `Elem` | `_h1 [] []` | | `Attr` | `_class_ "my-class"` | | `Text` | `_text "Hello world!"` | | `Text` shortcuts | `_h1' "Hello world"` (note the trailing apostrophe) | ```fsharp let markup = _div [ _class_ "heading" ] [ _h1' "Hello world!" ] ``` `SelfClosingNode` elements are self-closing tags. Represented as functions that receive one input: attributes. ```fsharp let markup = _div [ _class_ "divider" ] [ _hr [] ] ``` Text is represented using the `TextNode` and created using one of the functions in the `Text` module. ```fsharp let markup = _div [] [ _p' "A paragraph" _p [] [ _textf "Hello %s" "Jim" ] _code [] [ _textEnc "
Hello
" ] // HTML encodes text before rendering ] ``` Attributes contain two subtypes as well, `KeyValueAttr` which represent key/value attributes or `NonValueAttr` which represent boolean attributes. ```fsharp let markup = _input [ _type_ "text"; _required_ ] ``` Most [JavaScript Events](https://developer.mozilla.org/en-US/docs/Web/Events) have also been mapped in the `Attr` module. All of these events are prefixed with the word "on" (i.e., `_onclick_`, `_onfocus_` etc.) ```fsharp let markup = _button [ _onclick_ "console.log(\"hello world\")" ] [ _text "Click me" ] ``` ## HTML Though Falco.Markup can be used to produce any markup. It is first and foremost an HTML library. ### Combining views to create complex output ```fsharp open Falco.Markup // Components let divider = _hr [ _class_ "divider" ] // Template let master (title : string) (content : XmlNode list) = _html [ _lang_ "en" ] [ _head [] [ _title [] [ _text title ] ] _body [] content ] // Views let homeView = master "Homepage" [ _h1' "Homepage" divider _p' "Lorem ipsum dolor sit amet, consectetur adipiscing." ] let aboutView = master "About Us" [ _h1' "About" divider _p' "Lorem ipsum dolor sit amet, consectetur adipiscing." ] ``` ### Strongly-typed views ```fsharp open Falco.Markup type Person = { FirstName : string LastName : string } let doc (person : Person) = _html [ _lang_ "en" ] [ _head [] [ _title [] [ _text "Sample App" ] ] _body [] [ _main [] [ _h1' "Sample App" _p' $"{person.First} {person.Last}" ] ] ] ``` ### Forms Forms are the lifeblood of HTML applications. A basic form using the markup module would like the following: ```fsharp let dt = DateTime.Now _form [ _methodPost_; _action_ "/submit" ] [ _label [ _for_' "name" ] [ _text "Name" ] _input [ _id_ "name"; _name_ "name"; _typeText_ ] _label [ _for_' "birthdate" ] [ _text "Birthday" ] _input [ _id_ "birthdate"; _name_ "birthdate"; _typeDate_; _valueDate_ dt ] _input [ _typeSubmit_ ] ] ``` Expanding on this, we can create a more complex form involving multiple inputs and input types as follows: ```fsharp _form [ _methodPost_; _action_ "/submit" ] [ _label [ _for_' "name" ] [ _text "Name" ] _input [ _id_ "name"; _name_ "name" ] _label [ _for_' "bio" ] [ _text "Bio" ] _textarea [ _name_ "id"; _name_ "bio" ] [] _label [ _for_' "hobbies" ] [ _text "Hobbies" ] _select [ _id_ "hobbies"; _name_ "hobbies"; _multiple_ ] [ _option [ _value_ "programming" ] [ _text "Programming" ] _option [ _value_ "diy" ] [ _text "DIY" ] _option [ _value_ "basketball" ] [ _text "Basketball" ] ] _fieldset [] [ _legend [] [ _text "Do you like chocolate?" ] _label [] [ _text "Yes" _input [ _typeRadio_; _name_ "chocolate"; _value_ "yes" ] ] _label [] [ _text "No" _input [ _typeRadio_; _name_ "chocolate"; _value_ "no" ] ] ] _fieldset [] [ _legend [] [ _text "Subscribe to our newsletter" ] _label [] [ _text "Receive updates about product" _input [ _typeCheckbox_; _name_ "newsletter"; _value_ "product" ] ] _label [] [ _text "Receive updates about company" _input [ _typeCheckbox_; _name_ "newsletter"; _value_ "company" ] ] ] _input [ _typeSubmit_ ] ] ``` A simple but useful _meta_-element `_control` can reduce the verbosity required to create form outputs. The same form would look like: ```fsharp _form [ _methodPost_; _action_ "/submit" ] [ _control "name" [] [ _text "Name" ] _controlTextarea "bio" [] [ _text "Bio" ] [] _controlSelect "hobbies" [ _multiple_ ] [ _text "Hobbies" ] [ _option [ _value_ "programming" ] [ _text "Programming" ] _option [ _value_ "diy" ] [ _text "DIY" ] _option [ _value_ "basketball" ] [ _text "Basketball" ] ] _fieldset [] [ _legend [] [ _text "Do you like chocolate?" ] _control "chocolate" [ _id_ "chocolate_yes"; _typeRadio_ ] [ _text "yes" ] _control "chocolate" [ _id_ "chocolate_no"; _typeRadio_ ] [ _text "no" ] ] _fieldset [] [ _legend [] [ _text "Subscribe to our newsletter" ] _control "newsletter" [ _id_ "newsletter_product"; _typeCheckbox_ ] [ _text "Receive updates about product" ] _control "newsletter" [ _id_ "newsletter_company"; _typeCheckbox_ ] [ _text "Receive updates about company" ] ] _input [ _typeSubmit_ ] ] ``` ### Attribute Value One of the more common places of sytanctic complexity is with `_value_` which expects, like all `Attr` functions, `string` input. Some helpers exist to simplify this. ```fsharp let dt = DateTime.Now _input [ _typeDate_; _valueStringf_ "yyyy-MM-dd" dt ] // you could also just use: _input [ _typeDate_; _valueDate_ dt ] // formatted to ISO-8601 yyyy-MM-dd // or, _input [ _typeMonth_; _valueMonth_ dt ] // formatted to ISO-8601 yyyy-MM // or, _input [ _typeWeek_; _valueWeek_ dt ] // formatted to Gregorian yyyy-W# // it works for TimeSpan too: let ts = TimeSpan(12,12,0) _input [ _typeTime_; _valueTime_ ts ] // formatted to hh:mm // there is a helper for Option too: let someTs = Some ts _input [ _typeTime_; _valueOption_ _valueTime_ someTs ] ``` ### Merging Attributes The markup module allows you to easily create components, an excellent way to reduce code repetition in your UI. To support runtime customization, it is advisable to ensure components (or reusable markup blocks) retain a similar function "shape" to standard elements. That being, `XmlAttribute list -> XmlNode list -> XmlNode`. This means that you will inevitably end up needing to combine your predefined `XmlAttribute list` with a list provided at runtime. To facilitate this, the `Attr.merge` function will group attributes by key, and intelligently concatenate the values in the case of additive attributes (i.e., `class`, `style` and `accept`). ```fsharp open Falco.Markup // Components let heading (attrs : XmlAttribute list) (content : XmlNode list) = // safely combine the default XmlAttribute list with those provided // at runtime let attrs' = Attr.merge [ _class_ "text-large" ] attrs _div [] [ _h1 [ attrs' ] content ] // Template let master (title : string) (content : XmlNode list) = _html [ _lang_ "en" ] [ _head [] [ _title [] [ _text title ] ] _body [] content ] // Views let homepage = master "Homepage" [ heading [ _class_ "red" ] [ _text "Welcome to the homepage" ] _p' "Lorem ipsum dolor sit amet, consectetur adipiscing." ] let homepage = master "About Us" [ heading [ _class_ "purple" ] [ _text "This is what we're all about" ] _p' "Lorem ipsum dolor sit amet, consectetur adipiscing." ] ``` ## Custom Elements & Attributes Every effort has been taken to ensure the HTML and SVG specs are mapped to functions in the module. In the event an element or attribute you need is missing, you can either file an [issue](https://github.com/pimbrouwers/Falco.Markup/issues), or more simply extend the module in your project. An example creating custom XML elements and using them to create a structured XML document: ```fsharp open Falco.Makrup module XmlElem = let books = Attr.create "books" let book = Attr.create "book" let name = Attr.create "name" module XmlAttr = let soldOut = Attr.createBool "soldOut" let xmlDoc = XmlElem.books [] [ XmlElem.book [ XmlAttr.soldOut ] [ XmlElem.name [] [ _text "To Kill A Mockingbird" ] ] ] let xml = renderXml xmlDoc ``` ## Template Fragments There are circumstances where you may want to render only a portion of your view. Especially common in [hypermedia driven](https://htmx.org/essays/hypermedia-driven-applications/) applications. Supporting [template fragments](https://htmx.org/essays/template-fragments/) is helpful in maintaining locality of behaviour, because it allows you to decompose a particular view for partial updates internally without pulling fragments of the template out to separate files for rendering, creating a large number of individual templates. Falco.Markup supports this pattern by way of the `renderFragment` function, which will traverse the provided `XmlNode` tree and render only the child node matching the provided `id`. Otherwise, gracefully returning an empty string if no match is found. ```fsharp open Falco.Markup let view = _div [ _id_ "my-div"; _class_ "my-class" ] [ _h1 [ _id_ "my-heading" ] [ _text "hello" ] ] let render = renderFragment doc "my-heading" // produces:

hello

``` ## SVG Much of the SVG spec has been mapped to element and attributes functions. There is also an SVG template to help initialize a new drawing with a valid viewbox. ```fsharp open Falco.Markup open Falco.Markup.Svg // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text#example let svgDrawing = Templates.svg (0, 0, 240, 80) [ _style [] [ _text ".small { font: italic 13px sans-serif; }" _text ".heavy { font: bold 30px sans-serif; }" _text ".Rrrrr { font: italic 40px serif; fill: red; }" ] _text [ _x_ "20"; _y_ "35"; _class_ "small" ] [ _text "My" ] _text [ _x_ "40"; _y_ "35"; _class_ "heavy" ] [ _text "cat" ] _text [ _x_ "55"; _y_ "55"; _class_ "small" ] [ _text "is" ] _text [ _x_ "65"; _y_ "55"; _class_ "Rrrrr" ] [ _text "Grumpy!" ] ] let svg = renderNode svgDrawing ``` [Next: Cross-site Request Forgery (XSRF)](cross-site-request-forgery.md) ================================================ FILE: documentation/migrating-from-v4-to-v5.md ================================================ # Migrating from v4.x to v5.x With Falco v5.x the main objective was to simplify the API and improve the overall devlopment experience long term. The idea being provide only what is necessary, or provides the most value in the most frequently developed areas. This document will attempt to cover the anticipated transformations necessary to upgrade from v4.x to v5.x. Pull requests are welcome for missing scenarios, thank you in advance for your help. ## `webHost` expression Perhaps the most significant change is the removal of the `webHost` expression, which attempted to make web application server construction more pleasant. Microsoft has made really nice strides in this area (i.e., `WebApplication`) and it's been difficult at times to stay sync with the breaking changes to the underlying interfaces. As such, we elected to remove it altogether. Below demonstrates how to migrate a "hello world" app from v4 to v5 by replacing the `webHost` expression with the Microsoft provided `WebApplicationBuilder`.
```fsharp // Falco v4.x open Falco webHost args { use_static_files endpoints [ get "/" (Response.ofPlainText "hello world") ] } ``` ```fsharp // Falco v5.x open Falco open Falco.Routing open Microsoft.AspNetCore.Builder // ^-- this import adds many useful extensions let endpoints = [ get "/" (Response.ofPlainText "Hello World!") // ^-- associate GET / to plain text HttpHandler ] let wapp = WebApplication.Create() wapp.UseRouting() .UseFalco(endpoints) // ^-- activate Falco endpoint source .Run() ```
## `configuration` expression The configuration expression has also been removed. Again, the idea being to try and get in the way of potentially evolving APIs as much as possible. Even more so in the areas where the code was mostly decorative. > Note: This example is entirely trivial since the `WebApplication.CreateBuilder()` configures a host with common, sensible defaults.
```fsharp open Falco open Falco.HostBuilder let config = configuration [||] { required_json "appsettings.json" optional_json "appsettings.Development.json" } webHost [||] { endpoints [] } ``` ```fsharp open Falco open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.Configuration // ^-- this import adds access to Configuration let bldr = WebApplication.CreateBuilder() let conf = bldr.Configuration .AddJsonFile("appsettings.json", optional = false) .AddJsonFile("appsettings.Development.json") let wapp = WebApplication.Create() let endpoints = [] wapp.UseRouting() .UseFalco(endpoints) .Run() ```
## `StringCollectionReader` replaced by `RequestData` For the most part, this upgrade won't require any changes for the end user. Especially if the continuation-style functions in the `Request` module were used. Explicit references to: `CookieCollectionReader`, `HeaderCollectionReader`, `RouteCollectionReader`, `QueryCollectionReader` will need to be updated to `RequestData`. `FormCollectionReader` has been replaced by `FormData`. ## Form Streaming Falco now automatically detects whether the form is transmiting `multipart/form-data`, which means deprecating the `Request` module streaming functions. - `Request.streamForm` becomes -> `Request.mapForm` - `Request.streamFormSecure` becomes -> `Request.mapFormSecure` - `Request.mapFormStream` becomes -> `Request.mapForm` - `Request.mapFormStreamSecure` becomes -> `Request.mapFormSecure` ## Removed `Services.inject<'T1 .. 'T5>` This type was removed because it continued to pose problems for certain code analysis tools. To continue using the service locator pattern, you can now use the more versatile `HttpContext` extension method `ctx.Plug()`. For example: ```fsharp let myHandler : HttpHandler = Services.inject (fun myService ctx -> let message = myService.CreateMessage() Response.ofPlainText $"{message}" ctx) // becomes let myHandler : HttpHandler = fun ctx -> let myService = ctx.Plug() let message = myService.CreateMessage() Response.ofPlainText $"{message}" ctx ``` ## `Xss` module renamed to `Xsrf` The `Xss` module has been renamed to `Xsrf` to better describe it's intent. ```fsharp //before: Xss.antiforgeryInput Xsrf.antiforgeryInput // .. //before: Xss.getToken Xsrf.getToken // .. //before: Xss.validateToken Xsrf.validateToken // .. ``` ## `Crypto` module removed The Crypto module provided functionality for: random numbers, salt generation and key derivation. The code in this module was really a veneer on top of the cryptographic providers in the base library. Extracting this code into your project would be dead simple. The [source](https://github.com/FalcoFramework/Falco/blob/25d828d832c0fde2dfff04775bea1eced9050458/src/Falco/Security.fs#L3) is permalinked here for such purposes. ## `Auth` module removed The `Auth` module functionality was ported one-to-one to the `Response` module. ================================================ FILE: documentation/migrating-from-v5-to-v6.md ================================================ # Migrating from v5.x to v6.x The objective of Falco v6.x is to continue to simplify the API and improve the overall development experience long term. The idea being provide only what is necessary, or provides the most value in the most frequently developed areas. ## `Request.getFormSecure`
```fsharp // Falco v5.x let myHandler : HttpHandler = fun ctx -> task { let! form = Request.getFormSecure ctx match form with | Some form -> // do something with form | None -> // handle failed csrf validation } ``` ```fsharp // Falco v6.x let myHandler : HttpHandler = fun ctx -> task { let! form = Request.getForm ctx match form.IsValid with | true -> // do something with form | false -> // handle failed csrf validation } ```
## `Request.mapFormSecure`
```fsharp // Falco v5.x type MyForm = { Name : string } let myHandler : HttpHandler = let formMap (form : FormData) : MyForm = // do something with form let handler (myForm : MyForm) : HttpHandler = // do something with myForm let handleInvalid : HttpHandler = // handle failed csrf validation Request.mapFormSecure formMap handler handleInvalid ``` ```fsharp // Falco v6.x type MyForm = { Name : string } let myHandler : HttpHandler = let formMap (form : FormData) : MyForm = match form.IsValid with | true -> Some // do something with form | false -> None // handle failed csrf validation let handler (myForm : MyForm option) : HttpHandler = match myForm with | Some myForm -> // do something with myForm | None -> // handle failed csrf validation Request.mapForm formMap handler ```
================================================ FILE: documentation/readme.md ================================================ # Welcome to Falco's Documentation Visit the [getting started](get-started.md) page for installation and a brief overview. There are also more detailed [examples](example-hello-world.md) that shows how to create a small but complete application with Falco. The rest of the docs describe each component of Falco in detail. ## Guides Falco depends only on the high-performance base components of .NET and ASP.NET Core, and provides a toolset to build a working full-stack web application. This section of the documentation explains the different parts of Falco and how they can be used, customized, and extended. - [Getting Started](get-started.md) - [Routing](routing.md) - [Writing responses](response.md) - [Accessing request data](request.md) - [View engine](markup.md) - Security - [Cross Site Request Forgery (XSRF)](cross-site-request-forgery.html) - [Authentication & Authorization](authentication.html) - [Host Configuration](host-configuration.md) - [Deployment](deployment.md) - Examples - [Hello World](example-hello-world.md) - [Hello World MVC](example-hello-world-mvc.md) - [Dependency Injection](example-dependency-injection.md) - [External View Engine](example-external-view-engine.md) - [Basic REST API](example-basic-rest-api.md) - [Open API](example-open-api.md) - [HTMX](example-htmx.md) ================================================ FILE: documentation/request.md ================================================ # Request Handling Falco exposes a __uniform API__ to obtain typed values from `IFormCollection`, `IQueryCollection`, `RouteValueDictionary`, `IHeaderCollection`, and `IRequestCookieCollection`. This is achieved by means of the `RequestData` type and it's derivative `FormData`. These abstractions are intended to make it easier to work with the url-encoded key/value collections. > Take note of the similarities when interacting with the different sources of request data. ## A brief aside on the key/value semantics `RequestData` is supported by a recursive discriminated union called `RequestValue` which represents a parsed key/value collection. The `RequestValue` parsing process provides some simple, yet powerful, syntax to submit objects and collections over-the-wire, to facilitate complex form and query submissions. ### Key Syntax: Object Notation Keys using dot notation are interpreted as complex (i.e., nested values) objects. Consider the following POST request: ``` POST /my-form HTTP/1.1 Host: foo.example Content-Type: application/x-www-form-urlencoded Content-Length: 46 user.name=john%20doe&user.email=abc@def123.com ``` This will be intepreted as the following `RequestValue`: ```fsharp RObject [ "user", RObject [ "name", RString "john doe" "email", RString "abc@def123.com" ] ] ``` See [form binding](#form-binding) for details on interacting with form data. ### Key Syntax: List Notation Keys using square bracket notation are interpreted as lists, which can include both primitives and [complex objects](#key-syntax-object-notation). Both indexed and non-indexed variants are supported. Consider the following request: ``` GET /my-search?name=john&season[0]=summer&season[1]=winter&hobbies[]=hiking HTTP/1.1 Host: foo.example Content-Type: application/x-www-form-urlencoded Content-Length: 68 ``` This will be interpreted as the following `RequestValue`: ```fsharp RObject [ "name", RString "john" "season", RList [ RString "summer"; RString "winter" ] "hobbies", RList [ RString "hking" ] ] ``` See [query binding](#query-binding) for details on interacting with form data. ## Request Data Access `RequestData` provides the ability to safely read primitive types from flat and nested key/value collections. ```fsharp let requestData : RequestData = // From: Route | Query | Form // Retrieve primitive options let str : string option = requestData.TryGetString "name" let flt : float option = requestData.TryGetFloat "temperature" // Retrieve primitive, or default let str : string = requestData.GetString "name" let strOrDefault : string = requestData.GetString ("name", "John Doe") let flt : float = requestData.GetFloat "temperature" // Retrieve primitive list let strList : string list = requestData.GetStringList "hobbies" let grades : int list = requestData.GetInt32List "grades" // Dynamic access, useful for nested/complex collections // Equivalent to: // requestData.Get("user").Get("email_address").AsString() let userEmail = requestData?user?email_address.AsString() ``` ## Route Binding Provides access to the values found in the `RouteValueDictionary`. ```fsharp open Falco // Assuming a route pattern of /{Name} let manualRouteHandler : HttpHandler = fun ctx -> let r = Request.getRoute ctx let name = r.GetString "Name" // Or, let name = r?Name.AsString() // Or, let name = r.TryGetString "Name" |> Option.defaultValue "" Response.ofPlainText name ctx let mapRouteHandler : HttpHandler = Request.mapRoute (fun r -> r.GetString "Name") Response.ofPlainText ``` ## Query Binding Provides access to the values found in the `IQueryCollection`, as well as the `RouteValueDictionary`. In the case of matching keys, the values in the `IQueryCollection` take precedence. ```fsharp open Falco type Person = { FirstName : string LastName : string } let form : HttpHandler = Response.ofHtmlCsrf view let manualQueryHandler : HttpHandler = fun ctx -> let q = Request.getQuery ctx let person = { FirstName = q.GetString ("FirstName", "John") // Get value or return default value LastName = q.GetString ("LastName", "Doe") } Response.ofJson person ctx let mapQueryHandler : HttpHandler = Request.mapQuery (fun q -> let first = q.GetString ("FirstName", "John") // Get value or return default value let last = q.GetString ("LastName", "Doe") { FirstName = first; LastName = last }) Response.ofJson ``` ## Form Binding Provides access to the values found in he `IFormCollection`, as well as the `RouteValueDictionary`. In the case of matching keys, the values in the `IFormCollection` take precedence. The `FormData` inherits from `RequestData` type also exposes the `IFormFilesCollection` via the `_.Files` member and `_.TryGetFile(name : string)` method. ```fsharp type Person = { FirstName : string LastName : string } let manualFormHandler : HttpHandler = fun ctx -> task { let! f : FormData = Request.getForm ctx let person = { FirstName = f.GetString ("FirstName", "John") // Get value or return default value LastName = f.GetString ("LastName", "Doe") } return! Response.ofJson person ctx } let mapFormHandler : HttpHandler = Request.mapForm (fun f -> let first = f.GetString ("FirstName", "John") // Get value or return default value let last = f.GetString ("LastName", "Doe") { FirstName = first; LastName = last }) Response.ofJson let mapFormSecureHandler : HttpHandler = Request.mapFormSecure (fun f -> // `Request.mapFormSecure` will automatically validate CSRF token for you. let first = f.GetString ("FirstName", "John") // Get value or return default value let last = f.GetString ("LastName", "Doe") { FirstName = first; LastName = last }) Response.ofJson (Response.withStatusCode 400 >> Response.ofEmpty) ``` ### `multipart/form-data` Binding Microsoft defines [large upload](https://docs.microsoft.com/en-us/aspnet/core/mvc/models/file-uploads#upload-large-files-with-streaming) as anything **> 64KB**, which well... is most uploads. Anything beyond this size and they recommend streaming the multipart data to avoid excess memory consumption. To make this process **a lot** easier Falco's form handlers will attempt to stream multipart form-data, or return an error message indicating the likely problem. ```fsharp let imageUploadHandler : HttpHandler = let formBinder (f : FormData) : IFormFile option = f.TryGetFormFile "profile_image" let uploadImage (profileImage : IFormFile option) : HttpHandler = // Process the uploaded file ... // Safely buffer the multipart form submission Request.mapForm formBinder uploadImage let secureImageUploadHandler : HttpHandler = let formBinder (f : FormData) : IFormFile option = f.TryGetFormFile "profile_image" let uploadImage (profileImage : IFormFile option) : HttpHandler = // Process the uploaded file ... let handleInvalidCsrf : HttpHandler = Response.withStatusCode 400 >> Response.ofEmpty // Safely buffer the multipart form submission Request.mapFormSecure formBinder uploadImage handleInvalidCsrf ``` ## JSON These handlers use the .NET built-in `System.Text.Json.JsonSerializer`. ```fsharp type Person = { FirstName : string LastName : string } let jsonHandler : HttpHandler = Response.ofJson { FirstName = "John" LastName = "Doe" } let mapJsonHandler : HttpHandler = let handleOk person : HttpHandler = let message = sprintf "hello %s %s" person.First person.Last Response.ofPlainText message Request.mapJson handleOk let mapJsonOptionsHandler : HttpHandler = let options = JsonSerializerOptions() options.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull let handleOk person : HttpHandler = let message = sprintf "hello %s %s" person.First person.Last Response.ofPlainText message Request.mapJsonOption options handleOk ``` [Next: View engine](markup.md) ================================================ FILE: documentation/response.md ================================================ # Response Writing The `HttpHandler` type is used to represent the processing of a request. It can be thought of as the eventual (i.e., asynchronous) completion and processing of an HTTP request, defined in F# as: `HttpContext -> Task`. Handlers will typically involve some combination of: [route inspection](request.md#route-binding), [form](request.md#form-binding)/[query](request.md#query-binding) binding, business logic and finally response writing. With access to the `HttpContext` you are able to inspect all components of the request, and manipulate the response in any way you choose. ## Plain Text responses ```fsharp let textHandler : HttpHandler = Response.ofPlainText "hello world" ``` ## HTML responses Write your views in plain F#, directly in your assembly, using the [Markup](markup.md) module. A performant F# DSL capable of generating any angle-bracket markup. Also available directly as a standalone [NuGet](https://www.nuget.org/packages/Falco.Markup) package. ```fsharp let htmlHandler : HttpHandler = let html = _html [ _lang_ "en" ] [ _head [] [] _body [] [ _h1' "Sample App" // shorthand for: `_h1 [] [ Text.raw "Sample App" ]` ] ] Response.ofHtml html // Automatically protect against XSS attacks let secureHtmlHandler : HttpHandler = let html token = _html [] [ _body [] [ _form [ _method_ "post" ] [ _input [ _name_ "first_name" ] _input [ _name_ "last_name" ] // using the CSRF HTML helper Xsrf.antiforgeryInput token _input [ _type_ "submit"; _value_ "Submit" ] ] ] ] Response.ofHtmlCsrf html ``` Alternatively, if you're using an external view engine and want to return an HTML response from a string literal, then you can use `Response.ofHtmlString`. ```fsharp let htmlHandler : HttpHandler = Response.ofHtmlString "..." ``` ## Template Fragments If you want to return a [fragment of HTML](https://htmx.org/essays/template-fragments/), for example when working with [htmx](https://htmx.org/), you can use `Response.ofFragment` (or `Response.ofFragmentCsrf`). This function takes an element ID as its first argument, and a `XmlNode` as its second argument. The server will return only the contents of the node with the specified ID. ```fsharp let fragmentHandler : HttpHandler = let html = _div [ _id_ "greeting" ] [ _h1 [ _id_ "heading" ] [ _text "Hello, World!" ] ] Response.ofFragment "heading" html ``` This will return only the contents of the `h1` element, i.e. `

Hello, World!

`. In the case of multiple elements with the same ID, the first one found will be returned. If no element with the specified ID is found, an empty response will be returned. ## JSON responses These handlers use the .NET built-in `System.Text.Json.JsonSerializer`. ```fsharp type Person = { First : string Last : string } let jsonHandler : HttpHandler = let name = { First = "John"; Last = "Doe" } Response.ofJson name let jsonOptionsHandler : HttpHandler = let options = JsonSerializerOptions() options.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull let name = { First = "John"; Last = "Doe" } Response.ofJsonOptions options name ``` ## Redirect (301/302) Response ```fsharp let oldUrlHandler : HttpHandler = Response.redirectPermanently "/new-url" // HTTP 301 let redirectUrlHandler : HttpHandler = Response.redirectTemporarily "/new-url" // HTTP 302 ``` ## Content Disposition ```fsharp let inlineBinaryHandler : HttpHandler = let contentType = "image/jpeg" let headers = [ HeaderNames.CacheControl, "no-store, max-age=0" ] let bytes = // ... binary data Response.ofBinary contentType headers bytes let attachmentHandler : HttpHandler = let filename = "profile.jpg" let contentType = "image/jpeg" let headers = [ HeaderNames.CacheControl, "no-store, max-age=0" ] let bytes = // ... binary data Response.ofAttachment filename contentType headers bytes ``` ## Response Modifiers Response modifiers can be thought of as the in-and-out modification of the `HttpResponse`. A preamble to writing and returning. Since these functions receive the `Httpcontext` as input and return it as the only output, they can take advantage of function compoistion. ### Set the status code of the response ```fsharp let notFoundHandler : HttpHandler = Response.withStatusCode 404 >> Response.ofPlainText "Not found" ``` ### Add a header(s) to the response ```fsharp let handlerWithHeaders : HttpHandler = Response.withHeaders [ "Content-Language", "en-us" ] >> Response.ofPlainText "Hello world" ``` ### Add a cookie to the response > IMPORTANT: *Do not* use this for authentication. Instead use the `Response.signInAndRedirect` and `Response.signOutAndRedirect` functions found in the [Authentication](authenication.md) module. ```fsharp let handlerWithCookie : HttpHandler = Response.withCookie "greeted" "1" >> Response.ofPlainText "Hello world" let handlerWithCookieOptions : HttpHandler = let options = CookieOptions() options.Expires <- DateTime.Now.Minutes(15) Response.withCookie options "greeted" "1" >> Response.ofPlainText "Hello world" ``` [Next: Request Handling](request.md) ================================================ FILE: documentation/routing.md ================================================ # Routing Routing is responsible for matching incoming HTTP requests and dispatching those requests to the app's `HttpHandler`s. The breakdown of [Endpoint Routing](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing#configuring-endpoint-metadata) is simple. Associate a specific [route pattern](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-template-reference) and an HTTP verb to an [`HttpHandler`](request.md) which represents the ongoing processing (and eventual return) of a request. Bearing this in mind, routing can practically be represented by a list of these "mappings" known in Falco as an `HttpEndpoint` which bind together: a route, verb and handler. > Note: All of the following examples are _fully functioning_ web apps. ```fsharp open Falco open Falco.Routing open Microsoft.AspNetCore.Builder let wapp = WebApplication.Create() let endpoints = [ get "/" (Response.ofPlainText "hello world") ] wapp.UseRouting() .UseFalco(endpoints) .Run() ``` The preceding example includes a single `HttpEndpoint`: - When an HTTP `GET` request is sent to the root URL `/`: - The `HttpHandler` shown executes. - `Hello World!` is written to the HTTP response using the [Response](response.md) module. - If the request method is not `GET` or the URL is not `/`, no route matches and an HTTP 404 is returned. The following example shows a more sophisticated `HttpEndpoint`: ```fsharp open Falco open Falco.Routing open Microsoft.AspNetCore.Builder let wapp = WebApplication.Create() let endpoints = [ get "/hello/{name:alpha}" (fun ctx -> let route = Request.getRoute ctx let name = route.GetString "name" let message = sprintf "Hello %s" name Response.ofPlainText message ctx) ] wapp.UseRouting() .UseFalco(endpoints) .Run() ``` The string `/hello/{name:alpha}` is a **route template**. It is used to configure how the endpoint is matched. In this case, the template matches: - A URL like `/hello/Ryan` - Any URL path that begins with `/hello/` followed by a sequence of alphabetic characters. `:alpha` applies a route constraint that matches only alphabetic characters. - Full route constraint reference: [https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraint-reference](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing#route-constraint-reference). The second segment of the URL path, `{name:alpha}`: - Is bound to the `name` parameter. - Is captured and stored in `HttpRequest.RouteValues`, which Falco exposes through a [uniform API](request.md) to obtain primitive typed values. An alternative way to express the `HttEndpoint` above is seen below. ```fsharp open Falco open Falco.Routing open Microsoft.AspNetCore.Builder let wapp = WebApplication.Create() let greetingHandler name : HttpHandler = let message = sprintf "Hello %s" name Response.ofPlainText message let endpoints = [ mapGet "/hello/{name:alpha}" (fun route -> route.GetString "name") greetingHandler ] wapp.UseRouting() .UseFalco(endpoints) .Run() ``` ## Multi-method Endpoints There are scenarios where you may want to accept multiple HTTP verbs to single a URL. For example, a `GET`/`POST` form submission. To create a "multi-method" endpoint, the `all` function accepts a list of HTTP Verb and HttpHandler pairs. ```fsharp open Falco open Falco.Markup open Microsoft.AspNetCore.Builder let form = Templates.html5 "en" [] [ _form [ _method_ "post" ] [ _input [ _name_ "name" ] _input [ _type_ "submit" ] ] ] let wapp = WebApplication.Create() let endpoints = [ get "/" (Response.ofPlainText "Hello from /") all "/form" [ GET, Response.ofHtml form POST, Response.ofEmpty ] ] wapp.UseRouting() .UseFalco(endpoints) .Run() ``` [Next: Response Writing](response.md) ================================================ FILE: examples/BasicRestApi/BasicRestApi.fs ================================================ namespace BasicRestApi open System.Data open Donald // ^-- external package that makes using databases simpler open Falco open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection open System.Data.SQLite // ^-- official SQLite package type Error = { Code : string Message : string } type User = { Username : string FullName : string } type IDbConnectionFactory = abstract member Create : unit -> IDbConnection type IStore<'TKey, 'TItem> = abstract member List : unit -> 'TItem list abstract member Create : 'TItem -> Result abstract member Read : 'TKey -> 'TItem option abstract member Delete : 'TKey -> Result type UserStore(dbConnection : IDbConnectionFactory) = let userOfDataReader (rd : IDataReader) = { Username = rd.ReadString "username" FullName = rd.ReadString "full_name" } interface IStore with member _.List() = use conn = dbConnection.Create() conn |> Db.newCommand "SELECT username, full_name FROM user" |> Db.query userOfDataReader member _.Create(user : User) = use conn = dbConnection.Create() try conn |> Db.newCommand " INSERT INTO user (username, full_name) SELECT @username , @full_name WHERE @username NOT IN ( SELECT username FROM user)" |> Db.setParams [ "username", SqlType.String user.Username "full_name", SqlType.String user.FullName ] |> Db.exec |> Ok with | :? DbExecutionException -> Error { Code = "FAILED"; Message = "Could not add user" } member _.Read(username : string) = use conn = dbConnection.Create() conn |> Db.newCommand " SELECT username , full_name FROM user WHERE username = @username" |> Db.setParams [ "username", SqlType.String username ] |> Db.querySingle userOfDataReader member _.Delete(username : string) = use conn = dbConnection.Create() try conn |> Db.newCommand "DELETE FROM user WHERE username = @username" |> Db.setParams [ "username", SqlType.String username ] |> Db.exec |> Ok with | :? DbExecutionException -> Error { Code = "FAILED"; Message = "Could not add user" } module ErrorResponse = let badRequest error : HttpHandler = Response.withStatusCode 400 >> Response.ofJson error let notFound : HttpHandler = Response.withStatusCode 404 >> Response.ofJson { Code = "404"; Message = "Not Found" } let serverException : HttpHandler = Response.withStatusCode 500 >> Response.ofJson { Code = "500"; Message = "Server Error" } module Route = let userIndex = "/users" let userAdd = "/users" let userView = "/users/{username}" let userRemove = "/users/{username}" module UserEndpoint = let index : HttpHandler = fun ctx -> let userStore = ctx.Plug>() let allUsers = userStore.List() Response.ofJson allUsers ctx let add : HttpHandler = fun ctx -> task { let userStore = ctx.Plug>() let! userJson = Request.getJson ctx let userAddResponse = match userStore.Create(userJson) with | Ok result -> Response.ofJson result ctx | Error error -> ErrorResponse.badRequest error ctx return! userAddResponse } let view : HttpHandler = fun ctx -> let userStore = ctx.Plug>() let route = Request.getRoute ctx let username = route?username.AsString() match userStore.Read(username) with | Some user -> Response.ofJson user ctx | None -> ErrorResponse.notFound ctx let remove : HttpHandler = fun ctx -> let userStore = ctx.Plug>() let route = Request.getRoute ctx let username = route?username.AsString() match userStore.Delete(username) with | Ok result -> Response.ofJson result ctx | Error error -> ErrorResponse.badRequest error ctx module App = let endpoints = [ get Route.userIndex UserEndpoint.index post Route.userAdd UserEndpoint.add get Route.userView UserEndpoint.view delete Route.userRemove UserEndpoint.remove ] module Program = [] let main args = let dbConnectionFactory = { new IDbConnectionFactory with member _.Create() = new SQLiteConnection("Data Source=BasicRestApi.sqlite3") } let initializeDatabase (dbConnection : IDbConnectionFactory) = use conn = dbConnection.Create() conn |> Db.newCommand "CREATE TABLE IF NOT EXISTS user (username, full_name)" |> Db.exec initializeDatabase dbConnectionFactory let bldr = WebApplication.CreateBuilder(args) bldr.Services .AddAntiforgery() .AddSingleton(dbConnectionFactory) .AddScoped, UserStore>() |> ignore let wapp = bldr.Build() let isDevelopment = wapp.Environment.EnvironmentName = "Development" wapp.UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage) .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorResponse.serverException) .UseRouting() .UseFalco(App.endpoints) .Run(ErrorResponse.notFound) 0 ================================================ FILE: examples/BasicRestApi/BasicRestApi.fsproj ================================================ net10.0 ================================================ FILE: examples/BasicRestApi/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information" } } } ================================================ FILE: examples/DependencyInjection/DependencyInjection.fs ================================================ open Falco open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection type IGreeter = abstract member Greet : name : string -> string type FriendlyGreeter() = interface IGreeter with member _.Greet(name : string) = $"Hello {name} 😀" let bldr = WebApplication.CreateBuilder() // ^-- create a configurable web application builder bldr.Services .AddSingleton() // ^-- register the greeter as singleton in the container |> ignore let endpoints = [ mapGet "/{name?}" (fun r -> r?name.AsStringNonEmpty("world")) (fun name ctx -> let greeter = ctx.Plug() // ^-- access our dependency from the container let greeting = greeter.Greet(name) // ^-- invoke our greeter.Greet(name) method Response.ofPlainText greeting ctx) ] let wapp = bldr.Build() // ^-- manifest our WebApplication wapp.UseRouting() .UseFalco(endpoints) .Run() ================================================ FILE: examples/DependencyInjection/DependencyInjection.fsproj ================================================ net10.0 ================================================ FILE: examples/ExternalViewEngine/ExternalViewEngine.fs ================================================ module Falco.Scriban.Program open Falco open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection open Scriban type ITemplate = abstract member Render : template: string * model: obj -> string type ScribanTemplate() = interface ITemplate with member _.Render(template, model) = let tmpl = Template.Parse template tmpl.Render(model) module Pages = let private renderPage pageTitle template viewModel : HttpHandler = fun ctx -> let templateService = ctx.Plug() // ^-- obtain our template service from the dependency container let pageContent = templateService.Render(template, viewModel) // ^-- render our template with the provided view model as string literal let htmlTemplate = """ {{ title }} {{ content }} """ // ^-- these triple quoted strings auto-escape characters like double quotes for us // very practical for things like HTML let html = templateService.Render(htmlTemplate, {| Title = pageTitle; Content = pageContent |}) Response.ofHtmlString html ctx // ^-- return template literal as "text/html; charset=utf-8" response let homepage : HttpHandler = fun ctx -> let query = Request.getQuery ctx // ^-- obtain access to strongly-typed representation of the query string let viewModel = {| Name = query?name.AsStringNonEmpty("World") |} // ^-- access 'name' from query, or default to 'World' let template = """

Hello {{ name }}!

""" renderPage $"Hello {viewModel.Name}" template viewModel ctx let notFound : HttpHandler = let template = """

Page not found

""" renderPage "Page Not Found" template {||} [] let main args = let bldr = WebApplication.CreateBuilder(args) bldr.Services .AddSingleton() // ^-- register ITemplates implementation as a dependency |> ignore let wapp = bldr.Build() let endpoints = [ get "/" Pages.homepage ] wapp.UseRouting() .UseFalco(endpoints) .Run(Pages.notFound) 0 // Exit code ================================================ FILE: examples/ExternalViewEngine/ExternalViewEngine.fsproj ================================================  net10.0 ================================================ FILE: examples/Falco.Examples.sln ================================================  Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "BasicRestApi", "BasicRestApi\BasicRestApi.fsproj", "{1F90D83C-08A1-45AE-8354-43A67103C4E0}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Falco", "..\src\Falco\Falco.fsproj", "{172FD81C-526D-4AFF-851B-22AD79011C92}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Htmx", "Htmx\Htmx.fsproj", "{6C0D3898-42FB-4EEA-975F-52675D7D0AA0}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "HelloWorldMvc", "HelloWorldMvc\HelloWorldMvc.fsproj", "{473C763D-B566-4FA4-928A-49710C521429}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "HelloWorld", "HelloWorld\HelloWorld.fsproj", "{031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "DependencyInjection", "DependencyInjection\DependencyInjection.fsproj", "{467C1D39-C674-4011-9F89-06276717A229}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "OpenApi", "OpenApi\OpenApi.fsproj", "{02615E3C-0917-4D78-B79C-3BCC06081D2F}" EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "ExternalViewEngine", "ExternalViewEngine\ExternalViewEngine.fsproj", "{AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU Debug|x64 = Debug|x64 Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU Release|x64 = Release|x64 Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Debug|Any CPU.Build.0 = Debug|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Debug|x64.ActiveCfg = Debug|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Debug|x64.Build.0 = Debug|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Debug|x86.ActiveCfg = Debug|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Debug|x86.Build.0 = Debug|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Release|Any CPU.ActiveCfg = Release|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Release|Any CPU.Build.0 = Release|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Release|x64.ActiveCfg = Release|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Release|x64.Build.0 = Release|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Release|x86.ActiveCfg = Release|Any CPU {1F90D83C-08A1-45AE-8354-43A67103C4E0}.Release|x86.Build.0 = Release|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Debug|Any CPU.Build.0 = Debug|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Debug|x64.ActiveCfg = Debug|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Debug|x64.Build.0 = Debug|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Debug|x86.ActiveCfg = Debug|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Debug|x86.Build.0 = Debug|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Release|Any CPU.ActiveCfg = Release|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Release|Any CPU.Build.0 = Release|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Release|x64.ActiveCfg = Release|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Release|x64.Build.0 = Release|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Release|x86.ActiveCfg = Release|Any CPU {172FD81C-526D-4AFF-851B-22AD79011C92}.Release|x86.Build.0 = Release|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Debug|Any CPU.Build.0 = Debug|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Debug|x64.ActiveCfg = Debug|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Debug|x64.Build.0 = Debug|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Debug|x86.ActiveCfg = Debug|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Debug|x86.Build.0 = Debug|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Release|Any CPU.ActiveCfg = Release|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Release|Any CPU.Build.0 = Release|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Release|x64.ActiveCfg = Release|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Release|x64.Build.0 = Release|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Release|x86.ActiveCfg = Release|Any CPU {6C0D3898-42FB-4EEA-975F-52675D7D0AA0}.Release|x86.Build.0 = Release|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Debug|Any CPU.Build.0 = Debug|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Debug|x64.ActiveCfg = Debug|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Debug|x64.Build.0 = Debug|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Debug|x86.ActiveCfg = Debug|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Debug|x86.Build.0 = Debug|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Release|Any CPU.ActiveCfg = Release|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Release|Any CPU.Build.0 = Release|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Release|x64.ActiveCfg = Release|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Release|x64.Build.0 = Release|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Release|x86.ActiveCfg = Release|Any CPU {473C763D-B566-4FA4-928A-49710C521429}.Release|x86.Build.0 = Release|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Debug|Any CPU.Build.0 = Debug|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Debug|x64.ActiveCfg = Debug|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Debug|x64.Build.0 = Debug|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Debug|x86.ActiveCfg = Debug|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Debug|x86.Build.0 = Debug|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Release|Any CPU.ActiveCfg = Release|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Release|Any CPU.Build.0 = Release|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Release|x64.ActiveCfg = Release|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Release|x64.Build.0 = Release|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Release|x86.ActiveCfg = Release|Any CPU {031A5120-0837-4EE8-BFDB-0E6C33D4ADF6}.Release|x86.Build.0 = Release|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Debug|Any CPU.Build.0 = Debug|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Debug|x64.ActiveCfg = Debug|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Debug|x64.Build.0 = Debug|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Debug|x86.ActiveCfg = Debug|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Debug|x86.Build.0 = Debug|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Release|Any CPU.ActiveCfg = Release|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Release|Any CPU.Build.0 = Release|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Release|x64.ActiveCfg = Release|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Release|x64.Build.0 = Release|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Release|x86.ActiveCfg = Release|Any CPU {467C1D39-C674-4011-9F89-06276717A229}.Release|x86.Build.0 = Release|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Debug|x64.ActiveCfg = Debug|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Debug|x64.Build.0 = Debug|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Debug|x86.ActiveCfg = Debug|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Debug|x86.Build.0 = Debug|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Release|Any CPU.Build.0 = Release|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Release|x64.ActiveCfg = Release|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Release|x64.Build.0 = Release|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Release|x86.ActiveCfg = Release|Any CPU {02615E3C-0917-4D78-B79C-3BCC06081D2F}.Release|x86.Build.0 = Release|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Debug|Any CPU.Build.0 = Debug|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Debug|x64.ActiveCfg = Debug|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Debug|x64.Build.0 = Debug|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Debug|x86.ActiveCfg = Debug|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Debug|x86.Build.0 = Debug|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Release|Any CPU.ActiveCfg = Release|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Release|Any CPU.Build.0 = Release|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Release|x64.ActiveCfg = Release|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Release|x64.Build.0 = Release|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Release|x86.ActiveCfg = Release|Any CPU {AC590F56-9EBB-46EA-AE36-91CA1E2EAC54}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection EndGlobal ================================================ FILE: examples/HelloWorld/HelloWorld.fs ================================================ open Falco open Falco.Routing open Microsoft.AspNetCore.Builder let wapp = WebApplication.Create() wapp.UseRouting() .UseFalco([ get "/" (Response.ofPlainText "Hello World!") ]) .Run(Response.ofPlainText "Not found") ================================================ FILE: examples/HelloWorld/HelloWorld.fsproj ================================================ net10.0 ================================================ FILE: examples/HelloWorldMvc/HelloWorldMvc.fs ================================================ namespace HelloWorldMvc open Falco open Falco.Markup open Falco.Routing open Microsoft.AspNetCore.Builder module Model = type NameGreeting = { Name : string } type Greeting = { Message : string } module Route = let index = "/" let greetPlainText = "/greet/text/{name?}" let greetJson = "/greet/json/{name?}" let greetHtml = "/greet/html/{name?}" module Url = let greetPlainText name = Route.greetPlainText.Replace("{name?}", name) let greetJson name = Route.greetJson.Replace("{name?}", name) let greetHtml name = Route.greetHtml.Replace("{name?}", name) module View = open Model let layout content = Templates.html5 "en" [ _link [ _href_ "/style.css"; _rel_ "stylesheet" ] ] content module GreetingView = let detail greeting = layout [ _h1' $"Hello {greeting.Name} using HTML" _hr [] _p' "Greet other ways:" _nav [] [ _a [ _href_ (Url.greetHtml greeting.Name) ] [ _text "Greet in HTML"] _text " | " _a [ _href_ (Url.greetPlainText greeting.Name) ] [ _text "Greet in plain text"] _text " | " _a [ _href_ (Url.greetJson greeting.Name) ] [ _text "Greet in JSON " ] ] ] module Controller = open Model open View /// Error page(s) module ErrorController = let notFound : HttpHandler = Response.withStatusCode 404 >> Response.ofHtml (layout [ _h1' "Not Found" ]) let serverException : HttpHandler = Response.withStatusCode 500 >> Response.ofHtml (layout [ _h1' "Server Error" ]) let endpoints = [ get "/error/not-found" notFound get "/error/server-exception" serverException ] module GreetingController = let index name = { Name = name } |> GreetingView.detail |> Response.ofHtml let plainTextDetail name = Response.ofPlainText $"Hello {name} using plain text" let jsonDetail name = let message = { Message = $"Hello {name} using JSON" } Response.ofJson message let endpoints = let mapRoute (r : RequestData) = r?name.AsStringNonEmpty("you") [ mapGet Route.index mapRoute index mapGet Route.greetPlainText mapRoute plainTextDetail mapGet Route.greetJson mapRoute jsonDetail mapGet Route.greetHtml mapRoute index ] module App = open Controller let endpoints = ErrorController.endpoints @ GreetingController.endpoints module Program = open Controller [] let main args = let wapp = WebApplication.Create(args) let isDevelopment = wapp.Environment.EnvironmentName = "Development" wapp.UseIf(isDevelopment, DeveloperExceptionPageExtensions.UseDeveloperExceptionPage) .UseIf(not(isDevelopment), FalcoExtensions.UseFalcoExceptionHandler ErrorController.serverException) .Use(StaticFileExtensions.UseStaticFiles) .UseRouting() .UseFalco(App.endpoints) .Run(ErrorController.notFound) 0 ================================================ FILE: examples/HelloWorldMvc/HelloWorldMvc.fsproj ================================================ net10.0 ================================================ FILE: examples/HelloWorldMvc/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information" } } } ================================================ FILE: examples/HelloWorldMvc/wwwroot/style.css ================================================ body { font-family: 'Comic Sans MS' } ================================================ FILE: examples/Htmx/Htmx.fs ================================================ open System open Falco open Falco.Markup open Falco.Routing open Falco.Htmx open Microsoft.AspNetCore.Builder module View = let template content = _html [ _lang_ "en" ] [ _head [] [ _script [ _src_ HtmxScript.cdnSrc ] [] ] _body [] content ] let clickAndSwap = template [ _h1' "Example: Click & Swap" _div [ _id_ "content" ] [ _button [ _id_ "clicker" Hx.get "/click" Hx.swapOuterHtml ] [ _text "Click Me" ] ] ] module Components = let resetter = _div [ _id_ "resetter" ] [ _h2' "Way to go! You clicked it!" _br [] _button [ Hx.get "/reset" Hx.swapOuterHtml Hx.targetCss "#resetter" ] [ _text "Reset" ] ] module App = let handleIndex : HttpHandler = Response.ofHtml View.clickAndSwap let handleClick : HttpHandler = Response.ofHtml View.Components.resetter let handleReset : HttpHandler = Response.ofFragment "clicker" View.clickAndSwap let endpoints = [ get "/" handleIndex get "/click" handleClick get "/reset" handleReset ] let wapp = WebApplication.Create() wapp.UseRouting() .UseFalco(App.endpoints) .Run() ================================================ FILE: examples/Htmx/Htmx.fsproj ================================================ net10.0 ================================================ FILE: examples/Htmx/appsettings.json ================================================ { "Logging": { "LogLevel": { "Default": "Information" } } } ================================================ FILE: examples/OpenApi/OpenApi.fs ================================================ open Falco open Falco.OpenApi open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Hosting type FortuneInput = { Name : string } type Fortune = { Description : string } module Fortune = let create age input = match age with | Some age when age > 0 -> { Description = $"{input.Name}, you will experience great success when you are {age + 3}." } | _ -> { Description = $"{input.Name}, your future is unclear." } [] let main args = let bldr = WebApplication.CreateBuilder(args) bldr.Services .AddFalcoOpenApi() .AddSwaggerGen() |> ignore let wapp = bldr.Build() wapp.UseHttpsRedirection() .UseSwagger() .UseSwaggerUI() |> ignore let endpoints = [ mapPost "/fortune" (fun r -> r?age.AsIntOption()) (fun ageOpt -> Request.mapJson (Fortune.create ageOpt >> Response.ofJson)) |> OpenApi.name "Fortune" |> OpenApi.summary "A mystic fortune teller" |> OpenApi.description "Get a glimpse into your future, if you dare." |> OpenApi.query [ { Type = typeof; Name = "Age"; Required = false } ] |> OpenApi.acceptsType typeof |> OpenApi.returnType typeof ] wapp.UseRouting() .UseFalco(endpoints) .Run() 0 ================================================ FILE: examples/OpenApi/OpenApi.fsproj ================================================ net10.0 ================================================ FILE: global.json ================================================ { "sdk": { "version": "10.0.100", "rollForward": "latestMinor", "allowPrerelease": false } } ================================================ FILE: site/Site.fs ================================================ open System open System.IO open System.Text.RegularExpressions open Falco.Markup open Markdig open Markdig.Syntax.Inlines open Markdig.Renderers open Markdig.Syntax type LayoutModel = { Title : string MainContent : string } type LayoutTwoColModel = { Title : string SideContent : XmlNode list MainContent : string } type ParsedMarkdownDocument = { Title : string Body : string } module Markdown = let render (markdown : string) : ParsedMarkdownDocument = // Render Markdown as HTML let pipeline = MarkdownPipelineBuilder() .UseAutoIdentifiers() .UsePipeTables() .UseAutoLinks() .Build() use sw = new StringWriter() let renderer = HtmlRenderer(sw) pipeline.Setup(renderer) |> ignore let doc = Markdown.Parse(markdown, pipeline) renderer.Render(doc) |> ignore sw.Flush() |> ignore let renderedMarkdown = sw.ToString() // Extract title let title = doc.Descendants() |> Seq.tryFind (fun x -> x.Level = 1) |> Option.bind (fun x -> x.Inline |> Seq.tryPick (fun i -> match i with | :? LiteralInline as literal -> Some(literal.ToString()) | _ -> None)) // Rewrite direct markdown doc links let body = Regex.Replace(renderedMarkdown.Replace("\"documentation/", "\"docs/"), "([a-zA-Z\-]+)\.md", "$1.html") { Title = title |> Option.defaultValue "" Body = body } let renderFile (path : string) = render (File.ReadAllText(path)) module View = let docsLinks = [ _h3 [] [ _text "Project Links" ] _a [ _href_ "/"] [ _text "Project Homepage" ] _a [ _class_ "db"; _href_ "https://github.com/FalcoFramework/Falco"; _targetBlank_ ] [ _text "Source Code" ] _a [ _class_ "db"; _href_ "https://github.com/FalcoFramework/Falco/issues"; _targetBlank_ ] [ _text "Issue Tracker" ] _a [ _class_ "db"; _href_ "https://github.com/FalcoFramework/Falco/discussions"; _targetBlank_ ] [ _text "Discussion" ] _a [ _class_ "db"; _href_ "https://twitter.com/falco_framework"; _targetBlank_ ] [ _text "Twitter" ] ] let docsNav = [ Text.h3 "Contents" _ul [ _class_ "nl3 f6" ] [ _li [] [ _a [ _href_ "get-started.html" ] [ _text "Getting Started" ] ] _li [] [ _a [ _href_ "routing.html" ] [ _text "Routing" ] ] _li [] [ _a [ _href_ "response.html" ] [ _text "Response Writing" ] ] _li [] [ _a [ _href_ "request.html" ] [ _text "Request Handling" ] ] _li [] [ _a [ _href_ "markup.html" ] [ _text "Markup" ] ] _li [] [ _text "Security" _ul [] [ _li [] [ _a [ _href_ "cross-site-request-forgery.html" ] [ _text "Cross Site Request Forgery (XSRF)" ] ] _li [] [ _a [ _href_ "authentication.html" ] [ _text "Authentication & Authorization" ] ] ] ] _li [] [ _a [ _href_ "host-configuration.html" ] [ _text "Host Configuration" ] ] _li [] [ _a [ _href_ "deployment.html" ] [ _text "Deployment" ] ] _li [] [ _text "Examples" _ul [] [ _li [] [ _a [ _href_ "example-hello-world.html" ] [ _text "Hello World" ] ] _li [] [ _a [ _href_ "example-hello-world-mvc.html" ] [ _text "Hello World MVC" ] ] _li [] [ _a [ _href_ "example-dependency-injection.html" ] [ _text "Dependency Injection" ] ] _li [] [ _a [ _href_ "example-external-view-engine.html" ] [ _text "External View Engine" ] ] _li [] [ _a [ _href_ "example-basic-rest-api.html" ] [ _text "Basic REST API" ] ] _li [] [ _a [ _href_ "example-open-api.html" ] [ _text "Open API" ] ] _li [] [ _a [ _href_ "example-htmx.html" ] [ _text "htmx" ] ] ] ] _li [] [ _a [ _href_ "migrating-from-v4-to-v5.html" ] [ _text "V5 Migration Guide" ] ] ] ] let private _layoutHead title = let title = if String.IsNullOrWhiteSpace(title) then "Falco - F# web toolkit for ASP.NET Core" else $"{title} - Falco Documentation" [ _meta [ _charset_ "UTF-8" ] _meta [ _httpEquiv_ "X-UA-Compatible"; _content_ "IE=edge, chrome=1" ] _meta [ _name_ "viewport"; _content_ "width=device-width, initial-scale=1" ] _title [] [ _text title ] _meta [ _name_ "description"; _content_ "A functional-first toolkit for building brilliant ASP.NET Core applications using F#." ] _link [ _rel_ "shortcut icon"; _href_ "/favicon.ico"; _type_ "image/x-icon" ] _link [ _rel_ "icon"; _href_ "/favicon.ico"; _type_ "image/x-icon" ] _link [ _rel_ "preconnect"; _href_ "https://fonts.gstatic.com" ] _link [ _href_ "https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap"; _rel_ "stylesheet" ] _link [ _href_ "/prism.css"; _rel_ "stylesheet" ] _link [ _href_ "/tachyons.css"; _rel_ "stylesheet" ] _link [ _href_ "/style.css"; _rel_ "stylesheet" ] _script [ _async_; _src_ "https://www.googletagmanager.com/gtag/js?id=G-D62HSJHMNZ" ] [] _script [] [ _text """window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', 'G-D62HSJHMNZ');""" ] ] let private _layoutFooter = _footer [ _class_ "cl pa3 bg-merlot" ] [ _div [ _class_ "f7 tc white-70" ] [ _text $"© 2020-{DateTime.Now.Year} Pim Brouwers & contributors." ] ] let layout (model : LayoutModel) = let topBar = _div [] [ _nav [ _class_ "flex flex-column flex-row-l items-center" ] [ _a [ _href_ "/" ] [ _img [ _src_ "/icon.svg"; _class_ "w3 pb3 pb0-l o-80 hover-o-100" ] ] _div [ _class_ "flex-grow-1-l tc tr-l" ] [ _a [ _href_ "/docs"; _title_ "Overview of Falco's key features"; _class_ "dib mh2 mh3-l no-underline white-90 hover-white" ] [ _text "docs" ] _a [ _href_ "https://github.com/FalcoFramework/Falco"; _title_ "Fork Falco on GitHub"; _alt_ "Falco GitHub Link"; _targetBlank_; _class_ "dib mh2 ml3-l no-underline white-90 hover-white" ] [ _text "code" ] _a [ _href_ "https://github.com/FalcoFramework/Falco/tree/master/examples"; _title_ "Falco code samples"; _alt_ "Faclo code samples link"; _class_ "dib ml2 mh3-l no-underline white-90 hover-white" ] [ _text "samples" ] _a [ _href_ "https://github.com/FalcoFramework/Falco/discussions"; _title_ "Need help?"; _alt_ "Faclo GitHub discussions link"; _class_ "dib ml2 mh3-l no-underline white-90 hover-white" ] [ _text "help" ] ] ] ] let greeting = _div [ _class_ "mw6 center pb5 noto tc fw4 lh-copy white" ] [ _h1 [ _class_ "mt4 mb3 fw4 f2" ] [ _text "Meet Falco." ] _h2 [ _class_ "mt0 mb4 fw4 f4 f3-l" ] [ _text "Falco is a toolkit for building fast and functional-first web applications using F#." ] _div [ _class_ "tc" ] [ _a [ _href_ "/docs/get-started.html"; _title_ "Learn how to get started using Falco"; _class_ "dib mh2 mb2 ph3 pv2 merlot bg-white ba b--white br2 no-underline" ] [ _text "Get Started" ] _a [ _href_ "#falco"; _class_ "dib mh2 ph3 pv2 white ba b--white br2 no-underline" ] [ _text "Learn More" ] ] ] let releaseInfo = _div [ _class_ "mb4 bt b--white-20 tc lh-solid" ] [ _a [ _href_ "https://www.nuget.org/packages/Falco"; _class_ "dib center ph1 ph4-l pv3 bg-merlot white no-underline ty--50"; _targetBlank_ ] [ _text "Latest release: 5.2.0 (December, 21, 2025)" ] ] let benefits = _div [ _class_ "cf tc lh-copy" ] [ _div [ _class_ "fl-l mw5 mw-none-l w-25-l center mb4 ph4-l br-l b--white-20" ] [ _img [ _src_ "/icons/fast.svg"; _class_ "w4 o-90" ] _h3 [ _class_ "mv2 white" ] [ _text "Fast & Lightweight" ] _div [ _class_ "mb3 white-90" ] [ _text "Optimized for speed and low memory usage." ] _a [ _href_ "https://web-frameworks-benchmark.netlify.app/result?l=fsharp"; _targetBlank_; _class_ "dib mh2 pa2 f6 white ba b--white br2 no-underline" ] [ _text "Learn More" ] ] _div [ _class_ "fl-l mw5 mw-none-l w-25-l center mb4 ph4-l br-l b--white-20" ] [ _img [ _src_ "/icons/easy.svg"; _class_ "w4 o-90" ] _h3 [ _class_ "mv2 white" ] [ _text "Easy to Learn" ] _div [ _class_ "mb3 white-90" ] [ _text "Simple, predictable, and easy to pick up." ] _a [ _href_ "/docs/get-started.html"; _title_ "Learn how to get started using Falco"; _class_ "dib mh2 pa2 f6 white ba b--white br2 no-underline" ] [ _text "Get Started" ] ] _div [ _class_ "fl-l mw5 mw-none-l w-25-l center mb4 ph4-l br-l b--white-20" ] [ _img [ _src_ "/icons/view.svg"; _class_ "w4 o-90" ] _h3 [ _class_ "mv2 white" ] [ _text "Native View Engine" ] _div [ _class_ "mb3 white-90" ] [ _text "Markup is written in F# and compiled." ] _a [ _href_ "/docs/markup.html"; _title_ "View examples of Falco markup module"; _class_ "dib mh2 pa2 f6 white ba b--white br2 no-underline" ] [ _text "See Examples" ] ] _div [ _class_ "fl-l mw5 mw-none-l w-25-l center mb4 ph4-l" ] [ _img [ _src_ "/icons/integrate.svg"; _class_ "w4 o-90" ] _h3 [ _class_ "mv2 white" ] [ _text "Customizable" ] _div [ _class_ "mb3 white-90" ] [ _text "Seamlessly integrates with ASP.NET." ] _a [ _href_ "https://github.com/FalcoFramework/Falco/tree/master/samples/ScribanExample"; _targetBlank_; _title_ "Example of incorporating a third-party view engine"; _class_ "dib mh2 pa2 f6 white ba b--white br2 no-underline" ] [ _text "Explore How" ] ] ] _html [ _lang_ "en"; ] [ _head [] (_layoutHead model.Title) _body [ _class_ "noto bg-merlot bg-dots bg-parallax" ] [ _header [ _class_ "pv3" ] [ _div [ _class_ "mw8 center pa3" ] [ topBar greeting releaseInfo benefits ] ] _div [ _class_ "h100vh bg-white" ] [ _div [ _class_ "cf mw8 center pv4 ph3" ] [ _main [] [ _text model.MainContent ] ] ] _layoutFooter _script [ _src_ "/prism.js" ] [] ] ] let layoutTwoCol (model : LayoutTwoColModel) = _html [ Attr.lang "en"; ] [ _head [] (_layoutHead model.Title) _body [ _class_ "noto lh-copy" ] [ _div [ _class_ "min-vh-100 mw9 center pa3 overflow-hidden" ] [ _nav [ _class_ "sidebar w-20-l fl-l mb3 mb0-l" ] [ _div [ _class_ "flex items-center" ] [ _a [ _href_ "/docs"; _class_ "db w3 w4-l" ] [ _img [ _src_ "/brand.svg"; _class_ "br3" ] ] _h2 [ _class_ "dn-l mt3 ml3 fw4 gray" ] [ _text "Falco Documentation" ] ] _div [ _class_ "dn db-l" ] model.SideContent ] _main [ _class_ "w-80-l fl-l pl3-l" ] [ _text model.MainContent ] ] _layoutFooter _script [ _src_ "/prism.js" ] [] ] ] module Docs = let build (docs : FileInfo[]) (buildDir : DirectoryInfo) = if not buildDir.Exists then buildDir.Create() for file in docs do let buildFilename, sideContent = if file.Name = "readme.md" then "index.html", View.docsLinks else Path.ChangeExtension(file.Name, ".html"), View.docsNav let parsedMarkdownDocument = Markdown.renderFile file.FullName let html = { Title = parsedMarkdownDocument.Title SideContent = sideContent MainContent = parsedMarkdownDocument.Body } |> View.layoutTwoCol |> renderHtml File.WriteAllText(Path.Join(buildDir.FullName, buildFilename), html) [] let main args = if args.Length = 0 then failwith "Must provide the working directory as the first argument" let workingDir = DirectoryInfo(if args.Length = 2 then args[1] else args[0]) // Clean build let buildDirPath = DirectoryInfo(Path.Join(workingDir.FullName, "../docs")) printfn "Clearing build directory...\n %s" buildDirPath.FullName if buildDirPath.Exists then for file in buildDirPath.EnumerateFiles("*.html", EnumerationOptions(RecurseSubdirectories = true)) do file.Delete() else buildDirPath.Create () printfn "Rendering homepage..." let indexMarkdown = Path.Join(workingDir.FullName, "../README.md") |> File.ReadAllText let mainContent = Markdown.render indexMarkdown let mainWithoutTitle = Regex.Replace(mainContent.Body, "", "", RegexOptions.Singleline).Trim() { Title = String.Empty MainContent = mainWithoutTitle } |> View.layout |> renderHtml |> fun text -> File.WriteAllText(Path.Join(buildDirPath.FullName, "index.html"), text) let docsDir = DirectoryInfo(Path.Join(workingDir.FullName, "../documentation")) let docsBuildDir = DirectoryInfo(Path.Join(buildDirPath.FullName, "../docs/docs")) printfn "Rendering docs...\n From: %s\n To: %s" docsDir.FullName docsBuildDir.FullName Docs.build (docsDir.GetFiles "*.md") docsBuildDir // Additional languages let languageCodes = [] for languageCode in languageCodes do printfn "Rendering /%s docs" languageCode let languageDir = DirectoryInfo(Path.Join(docsDir.FullName, languageCode)) let languageBuildDir = DirectoryInfo(Path.Join(docsBuildDir.FullName, languageCode)) Docs.build (languageDir.GetFiles()) languageBuildDir 0 ================================================ FILE: site/Site.fsproj ================================================  Exe net10.0 ================================================ FILE: src/Falco/Core.fs ================================================ namespace Falco open System open System.Threading.Tasks open Microsoft.AspNetCore.Http /// The eventual return of asynchronous HttpContext processing. type HttpHandler = HttpContext -> Task module HttpHandler = /// Convert HttpHandler to a RequestDelegate. let toRequestDelegate (handler : HttpHandler) = new RequestDelegate(handler) /// In-and-out processing of a HttpContext. type HttpResponseModifier = HttpContext -> HttpContext /// Http verb type HttpVerb = | GET | HEAD | POST | PUT | PATCH | DELETE | OPTIONS | TRACE | ANY override x.ToString() = match x with | GET -> HttpMethods.Get | HEAD -> HttpMethods.Head | POST -> HttpMethods.Post | PUT -> HttpMethods.Put | PATCH -> HttpMethods.Patch | DELETE -> HttpMethods.Delete | OPTIONS -> HttpMethods.Options | TRACE -> HttpMethods.Trace | ANY -> String.Empty ================================================ FILE: src/Falco/Falco.fsproj ================================================ Falco 6.0.0-alpha1 A functional-first toolkit for building brilliant ASP.NET Core applications using F#. Copyright 2025 Pim Brouwers Pim Brouwers and contributors en-CA net8.0;net9.0;net10.0 embedded Library true false true Falco fsharp;functional;asp.net core;asp.net;.net core;routing;view engine;web;falco;falco-sharp; https://github.com/FalcoFramework/Falco Apache-2.0 icon.png README.md true git https://github.com/FalcoFramework/Falco true true true true $(PackageIconUrl) ================================================ FILE: src/Falco/Multipart.fs ================================================ namespace Falco open System open System.IO open System.Net open System.Threading open System.Threading.Tasks open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.WebUtilities open Microsoft.Extensions.Primitives open Microsoft.Net.Http.Headers module Multipart = [] let DefaultMaxSize = 32L * 1024L * 1024L // 32mb type private MultipartSectionData = | NoMultipartData | FormValueData of key : string * value : string | FormFileData of FormFile type private MultipartSection with /// Attempts to obtain encoding from content type, default to UTF8. static member private GetEncodingFromContentType(section : MultipartSection) = match MediaTypeHeaderValue.TryParse(StringSegment(section.ContentType)) with | false, _ -> System.Text.Encoding.UTF8 | true, parsed -> parsed.Encoding /// Safely obtains the content disposition header value. static member private TryGetContentDisposition(section : MultipartSection) = match ContentDispositionHeaderValue.TryParse(StringSegment(section.ContentDisposition)) with | false, _ -> None | true, parsed -> Some parsed /// Stream with size check to avoid unbounded memory growth. /// Streams the current section asynchronously with a specified maximum size. /// /// - `ct`: A `CancellationToken` to observe while waiting for the task to complete. /// - `maxSize`: The maximum size, in total bytes, allowed for the section being streamed. member private x.StreamSectionAsync(ct : CancellationToken, maxSize : int64) = task { match MultipartSection.TryGetContentDisposition(x) with | Some cd when cd.IsFileDisposition() && cd.FileName.HasValue && cd.Name.HasValue -> // cannot be disposed until the FormFile is fully read let str = new MemoryStream() let mutable bytesRead = 0L let buffer = Array.zeroCreate 65536 // 64KB chunks let mutable shouldRead = true while shouldRead do let! count = x.Body.ReadAsync(buffer, 0, buffer.Length, ct) match count with | 0 -> shouldRead <- false | n -> bytesRead <- bytesRead + int64 n if bytesRead > maxSize then raise (InvalidOperationException $"File exceeds maximum size of {maxSize} bytes") do! str.WriteAsync(buffer, 0, n, ct) let safeFileName = WebUtility.HtmlEncode cd.FileName.Value let file = new FormFile(str, int64 0, str.Length, cd.Name.Value, safeFileName) // necessary to prevent null reference exception when setting // properties below. See: // https://github.com/dotnet/aspnetcore/blob/ca2238e75173d1f04ff0664c53dc443716a01b9d/src/Http/Http/src/FormFile.cs#L48 file.Headers <- new HeaderDictionary() file.ContentType <- x.ContentType file.ContentDisposition <- x.ContentDisposition return FormFileData file | Some cd when cd.IsFormDisposition() && cd.Name.HasValue -> let key = HeaderUtilities.RemoveQuotes(cd.Name).Value let encoding = MultipartSection.GetEncodingFromContentType(x) use str = new StreamReader(x.Body, encoding, true, 8192, true) let! requestValue = str.ReadToEndAsync() return FormValueData (key, requestValue) | Some _ | None -> return NoMultipartData } type MultipartReader with /// Streams the multipart sections and accumulates form values and files into an `IFormCollection`. /// /// - `ct`: A `CancellationToken` to observe while waiting for the task to complete. /// - `maxSize`: The maximum size, in total bytes, allowed for each individual section being streamed. member x.StreamSectionsAsync(ct : CancellationToken, ?maxSize : int64) = task { let formData = new KeyValueAccumulator() let formFiles = new FormFileCollection() let mutable shouldContinue = true while shouldContinue do let! section = x.ReadNextSectionAsync(ct) match isNull section with | true -> shouldContinue <- false | false -> // default to max file size if not provided let! sectionData = section.StreamSectionAsync(ct, defaultArg maxSize DefaultMaxSize) match sectionData with | FormFileData file -> formFiles.Add(file) | FormValueData (key, value) -> formData.Append(key, value) | NoMultipartData -> shouldContinue <- false let formCollection = FormCollection(formData.GetResults(), formFiles) :> IFormCollection return formCollection } type HttpRequest with /// Determines if the content type contains multipart. member internal x.IsMultipart () : bool = x.ContentType.IndexOf("multipart/", StringComparison.OrdinalIgnoreCase) >= 0 member private x.GetBoundary() = // Content-Type: multipart/form-data; boundary="----WebKitFormBoundarymx2fSWqWSd0OxQqq" // The spec at https://tools.ietf.org/html/rfc2046#section-5.1 states that 70 characters is a reasonable limit. let lengthLimit = 70 let contentType = MediaTypeHeaderValue.Parse(StringSegment(x.ContentType)) let boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value; match boundary with | b when isNull b -> None | b when b.Length > lengthLimit -> None | b -> Some b /// Attempts to stream the HttpRequest body into IFormCollection. member x.StreamFormAsync (ct : CancellationToken, ?maxFileSize : int64) : Task = task { match x.IsMultipart(), x.GetBoundary() with | true, Some boundary -> let multipartReader = new MultipartReader(boundary, x.Body) // default to 32mb max file size if not provided let! formCollection = multipartReader.StreamSectionsAsync(ct, defaultArg maxFileSize DefaultMaxSize) return formCollection | _, None | false, _ -> return FormCollection.Empty } ================================================ FILE: src/Falco/Request.fs ================================================ [] module Falco.Request open System open System.IO open System.Security.Claims open System.Text open System.Text.Json open System.Threading open System.Threading.Tasks open Microsoft.AspNetCore.Authentication open Microsoft.AspNetCore.Http open Falco.Multipart open Falco.Security open Falco.StringUtils let internal defaultJsonOptions = let options = JsonSerializerOptions() options.AllowTrailingCommas <- true options.PropertyNameCaseInsensitive <- true options.TypeInfoResolver <- JsonSerializerOptions.Default.TypeInfoResolver options.MakeReadOnly() // optimize for reuse options /// Obtains the `HttpVerb` of the request. let getVerb (ctx : HttpContext) : HttpVerb = match ctx.Request.Method with | m when strEquals m HttpMethods.Get -> GET | m when strEquals m HttpMethods.Head -> HEAD | m when strEquals m HttpMethods.Post -> POST | m when strEquals m HttpMethods.Put -> PUT | m when strEquals m HttpMethods.Patch -> PATCH | m when strEquals m HttpMethods.Delete -> DELETE | m when strEquals m HttpMethods.Options -> OPTIONS | m when strEquals m HttpMethods.Trace -> TRACE | _ -> ANY /// Streams the request body into a string, up to the maximum size defined by `maxSize`. /// Cannot be called after the body has already been read, and will throw an /// exception if the body is not seekable and empty. /// /// - `maxSize`: The maximum size, in total bytes, allowed for the body being read. let getBodyStringOptions (maxSize : int64) (ctx : HttpContext) : Task = task { use tokenSource = new CancellationTokenSource() let str = new MemoryStream() let mutable bytesRead = 0L let buffer = Array.zeroCreate 65536 // 64KB chunks let mutable shouldRead = true while shouldRead do let! count = ctx.Request.Body.ReadAsync(buffer, 0, buffer.Length, tokenSource.Token) match count with | 0 -> shouldRead <- false | n -> bytesRead <- bytesRead + int64 n if bytesRead > maxSize then raise (InvalidOperationException $"Body exceeds maximum size of {maxSize} bytes") do! str.WriteAsync(buffer, 0, n, tokenSource.Token) str.Seek(0L, SeekOrigin.Begin) |> ignore return Encoding.UTF8.GetString(str.ToArray()) } /// Streams the request body into a string, up to a maximum body size of `Multipart.DefaultMaxSize`. /// Cannot be called after the body has already been read, and will throw an /// exception if the body is not seekable and empty. /// /// Note: `Multipart.DefaultMaxSize` is used as the default maximum body size for /// consistency with multipart form data processing, but can be overridden by using /// `getBodyStringOptions` directly. let getBodyString (ctx : HttpContext) : Task = getBodyStringOptions Multipart.DefaultMaxSize ctx /// Retrieves the cookie from the request. Returns a `RequestData` containing the cookie values. let getCookies (ctx : HttpContext) : RequestData = RequestValue.parseCookies ctx.Request.Cookies |> RequestData /// Retrieves the headers from the request. Returns a `RequestData` containing the header values. let getHeaders (ctx : HttpContext) : RequestData = RequestValue.parseHeaders ctx.Request.Headers |> RequestData /// Retrieves all route values from the request, including query string. Returns a `RequestData` containing the route and query values. let getRoute (ctx : HttpContext) : RequestData = RequestValue.parseRoute (ctx.Request.RouteValues, ctx.Request.Query) |> RequestData /// Retrieves the query string and route values from the request. Returns a `RequestData` containing the query values. let getQuery (ctx : HttpContext) : RequestData = RequestValue.parseQuery ctx.Request.Query |> RequestData /// Retrieves the form collection and route values from the request. /// /// Performs CSRF validation for POST, PUT, PATCH, DELETE requests, if antiforgery /// services are registered and a token is provided in the request. /// /// Automatically detects if request is multipart/form-data, and will enable streaming. /// /// Note: Consumes the request body, so should not be called after body has already been read. /// /// - `maxSize`: The maximum size, in total bytes, allowed for the body being read. let getFormOptions (maxSize : int64) (ctx : HttpContext) : Task = task { if ctx.Request.ContentLength.HasValue && ctx.Request.ContentLength.Value > maxSize then return FormData.Invalid else let! isAuth = Xsrf.validateToken ctx if isAuth then use tokenSource = new CancellationTokenSource() let! form = if ctx.Request.IsMultipart() then ctx.Request.StreamFormAsync (tokenSource.Token, maxSize) else ctx.Request.ReadFormAsync tokenSource.Token let files = if isNull form.Files then None else Some form.Files let requestValue = RequestValue.parseForm (form, Some ctx.Request.RouteValues) return FormData(requestValue, files) else return FormData.Invalid } /// Retrieves the form collection and route values from the request. /// /// Performs CSRF validation for POST, PUT, PATCH, DELETE requests, if antiforgery /// services are registered and a token is provided in the request. /// /// Automatically detects if request is multipart/form-data, and will enable streaming. /// /// Uses a default maximum body size of `Multipart.DefaultMaxSize` for consistency /// with multipart form data processing, but can be overridden by using /// `getFormOptions` directly. /// /// Note: Consumes the request body, so should not be called after body has already been read. let getForm (ctx : HttpContext) : Task = getFormOptions Multipart.DefaultMaxSize ctx /// Attempts to bind request body using System.Text.Json and provided /// JsonSerializerOptions. If the body is empty or not JSON, returns the default /// value of 'T. /// /// - `options`: The `JsonSerializerOptions` to use during deserialization. let getJsonOptions<'T> (options : JsonSerializerOptions) (ctx : HttpContext) : Task<'T> = task { try if not (ctx.Request.HasJsonContentType()) then ctx.Response.StatusCode <- StatusCodes.Status415UnsupportedMediaType return JsonSerializer.Deserialize<'T>("{}", options) elif ctx.Request.Body.CanSeek && ctx.Request.Body.Length = 0L then return JsonSerializer.Deserialize<'T>("{}", options) else use tokenSource = new CancellationTokenSource() let! json = JsonSerializer.DeserializeAsync<'T>(ctx.Request.Body, options, tokenSource.Token).AsTask() return json with | :? NotSupportedException as _ -> return JsonSerializer.Deserialize<'T>("{}", options) } /// Attempts to bind request body using System.Text.Json and default /// `JsonSerializerOptions`. If the body is empty or not JSON, returns the default /// value of 'T. let getJson<'T> (ctx : HttpContext) = getJsonOptions<'T> defaultJsonOptions ctx // ------------ // Handlers // ------------ /// Buffers the current HttpRequest body into a string and provides to next `HttpHandler`. /// Note: Uses `getBodyString`, which has a default maximum body size of `Multipart.DefaultMaxSize` /// for consistency with multipart form data processing, but can be overridden /// by using `getBodyStringOptions` directly. /// /// - `next`: The next `HttpHandler` to invoke, which takes the buffered body string as input. let bodyString (next : string -> HttpHandler) : HttpHandler = fun ctx -> task { let! body = getBodyString ctx return! next body ctx } /// Projects cookie values onto 'T and provides to next HttpHandler. /// /// - `map`: A function that maps the cookie values from the request into a new type 'T. /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapCookies (map : RequestData -> 'T) (next : 'T -> HttpHandler) : HttpHandler = fun ctx -> getCookies ctx |> map |> fun route -> next route ctx /// Projects header values onto 'T and provides to next HttpHandler. /// /// - `map`: A function that maps the header values from the request into a new type 'T. /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapHeaders (map : RequestData -> 'T) (next : 'T -> HttpHandler) : HttpHandler = fun ctx -> getHeaders ctx |> map |> fun route -> next route ctx /// Projects route values onto 'T and provides to next HttpHandler. /// /// - `map`: A function that maps the route values from the request into a new type 'T. /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapRoute (map : RequestData -> 'T) (next : 'T -> HttpHandler) : HttpHandler = fun ctx -> getRoute ctx |> map |> fun route -> next route ctx /// Projects query string onto 'T and provides to next HttpHandler. /// /// - `map`: A function that maps the query string values from the request into a new type 'T. /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapQuery (map : RequestData -> 'T) (next : 'T -> HttpHandler) : HttpHandler = fun ctx -> getQuery ctx |> map |> fun query -> next query ctx /// Projects form data onto 'T and provides to next HttpHandler. /// /// Performs CSRF validation for HTTP POST, PUT, PATCH, DELETE verbs, if antiforgery /// services are registered and a token is provided in the request. /// /// Automatically detects if request is content-type: multipart/form-data, and /// if so, will enable streaming. /// /// - `maxSize`: The maximum size, in total bytes, allowed for the body being read. Should be set according to expected form data size and server limits. /// - `map`: A function that maps the form data from the request into a new type 'T. /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapFormOptions (maxSize : int64) (map : FormData -> 'T) (next : 'T -> HttpHandler) : HttpHandler = fun ctx -> task { let! form = getFormOptions maxSize ctx return! next (map form) ctx } /// Projects form dta onto 'T and provides to next HttpHandler. /// /// Performs CSRF validation for HTTP POST, PUT, PATCH, DELETE verbs, if antiforgery /// services are registered and a token is provided in the request. /// /// Automatically detects if request is content-type: multipart/form-data, and /// if so, will enable streaming. /// /// Uses `getForm` with a default maximum body size of `Multipart.DefaultMaxSize` for consistency /// with multipart form data processing, but can be overridden by using `mapFormOptions` directly. /// /// - `map`: A function that maps the form data from the request into a new type 'T. /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapForm (map : FormData -> 'T) (next : 'T -> HttpHandler) : HttpHandler = fun ctx -> task { let! form = getForm ctx return! next (map form) ctx } /// Validates the CSRF of the current request. /// /// - `handleOk`: The `HttpHandler` to invoke if the CSRF token is valid or if validation is not applicable. /// - `handleInvalidToken`: The `HttpHandler` to invoke if the CSRF token let validateCsrfToken (handleOk : HttpHandler) (handleInvalidToken : HttpHandler) : HttpHandler = fun ctx -> task { let! isValid = Xsrf.validateToken ctx let respondWith = match isValid with | true -> handleOk | false -> handleInvalidToken return! respondWith ctx } /// Projects JSON using custom JsonSerializerOptions onto 'T and provides to next /// `HttpHandler`, throws `JsonException` if errors occur during deserialization. /// If the body is empty or not JSON, returns the default value of 'T. /// /// - `options`: The `JsonSerializerOptions` to use during deserialization. /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapJsonOptions<'T> (options : JsonSerializerOptions) (next : 'T -> HttpHandler) : HttpHandler = fun ctx -> task { let! json = getJsonOptions options ctx return! next json ctx } /// Projects JSON onto 'T and provides to next `HttpHandler`, throws `JsonException` /// if errors occur during deserialization. If the body is empty or not JSON, returns /// the default value of 'T. /// /// Uses `getJson` with default `JsonSerializerOptions`, but can be overridden by using `mapJsonOptions` directly. /// /// - `next`: The next `HttpHandler` to invoke, which takes the mapped 'T as input. let mapJson<'T> (next : 'T -> HttpHandler) : HttpHandler = mapJsonOptions<'T> defaultJsonOptions next // ------------ // Authentication // ------------ /// Attempts to authenticate the current request using the provided scheme and /// passes AuthenticateResult into next HttpHandler. Does not modify the /// `HttpContext.User` or authentication status, and does not challenge or forbid on its own. /// /// - `authScheme`: The authentication scheme to use when authenticating the request. This should match the scheme used in your authentication configuration. /// - `next`: The next `HttpHandler` to invoke, which takes the `AuthenticateResult` as input. let authenticate (authScheme : string) (next : AuthenticateResult -> HttpHandler) : HttpHandler = fun ctx -> task { let! authenticateResult = ctx.AuthenticateAsync authScheme return! next authenticateResult ctx } /// Authenticate the current request using the default authentication scheme. Proceeds /// if the authentication status of current `IPrincipal` is true. /// /// Note: The default authentication scheme can be configured using /// `Microsoft.AspNetCore.Authentication.AuthenticationOptions.DefaultAuthenticateScheme.` /// /// - `authScheme`: The authentication scheme to use when authenticating the request. This should match the scheme used in your authentication configuration. /// - `handleOk`: The `HttpHandler` to invoke if the user is authenticated. If the user is not authenticated, a 403 Forbidden response will be returned. let ifAuthenticated (authScheme : string) (handleOk : HttpHandler) : HttpHandler = authenticate authScheme (fun authenticateResult ctx -> if authenticateResult.Succeeded then handleOk ctx else ctx.ForbidAsync()) /// Proceeds if the authentication status of current `IPrincipal` is true and /// they exist in a list of roles. /// /// The roles are checked using `ClaimsPrincipal.IsInRole`, so the role claim type /// is determined by the authentication handler in use. For example, with JWT Bearer /// authentication, the role claim type is typically "roles" or "role", but with /// cookie authentication it may be different depending on how claims are set up. /// /// Note: This function assumes that the authentication handler populates the user's /// claims with their roles in a way that `ClaimsPrincipal.IsInRole` can check. Make /// sure your authentication setup is configured accordingly for role-based authorization /// to work with this function. /// /// - `authScheme`: The authentication scheme to use when authenticating the request. This should match the scheme used in your authentication configuration. /// - `roles`: A sequence of roles to check against the authenticated user's claims. If the user is in any of the specified roles, they will be allowed to proceed. /// - `handleOk`: The `HttpHandler` to invoke if the user is authenticated and in one of the specified roles. If the user is not authenticated or not in any of the roles, a 403 Forbidden response will be returned. let ifAuthenticatedInRole (authScheme : string) (roles : string seq) (handleOk : HttpHandler) : HttpHandler = authenticate authScheme (fun authenticateResult ctx -> let isInRole = Seq.exists authenticateResult.Principal.IsInRole roles match authenticateResult.Succeeded, isInRole with | true, true -> handleOk ctx | _ -> ctx.ForbidAsync()) /// Proceeds if the authentication status of current IPrincipal is true and has /// a specific scope. /// /// The scope is checked by looking for a claim of type "scope" with the specified /// value and issuer. This is commonly used in token-based authentication scenarios, /// such as with JWTs, where scopes are included as claims in the token. The issuer /// is also checked to ensure that the scope claim is coming from the expected authority. /// /// Note: This function assumes that the authentication handler populates the user's /// claims with their scopes in a claim of type "scope". Make sure your authentication /// setup is configured accordingly for scope-based authorization to work with this function. /// /// - `authScheme`: The authentication scheme to use when authenticating the request. This should match the scheme used in your authentication configuration. /// - `issuer`: The expected issuer of the scope claim to check against. This should match the authority that issues the tokens containing the scope claims. /// - `scope`: The specific scope value to check for in the user's claims. The user must have a claim of type "scope" with this value (and the correct issuer) to be allowed to proceed. /// - `handleOk`: The `HttpHandler` to invoke if the user is authenticated and has the specified scope. If the user is not authenticated or does not have the required scope, a 403 Forbidden response will be returned. let ifAuthenticatedWithScope (authScheme : string) (issuer : string) (scope : string) (handleOk : HttpHandler) : HttpHandler = authenticate authScheme (fun authenticateResult ctx -> if authenticateResult.Succeeded then let hasScope = let predicate (claim : Claim) = strEquals claim.Issuer issuer && strEquals claim.Type "scope" match Seq.tryFind predicate authenticateResult.Principal.Claims with | Some claim -> Array.contains scope (strSplit [|' '|] claim.Value) | None -> false if hasScope then handleOk ctx else ctx.ForbidAsync() else ctx.ForbidAsync()) /// Proceeds if the authentication status of current IPrincipal is false. /// /// This can be used to allow access to certain handlers only for unauthenticated /// users, such as a login or registration page. If the user is authenticated, a /// 403 Forbidden response will be returned. /// /// Note: This function checks if the authentication attempt succeeded, which means /// the user is authenticated. If the authentication attempt did not succeed /// (i.e., the user is not authenticated), it allows access to the specified handler. /// Make sure your authentication configuration is set up correctly for this to /// work as intended. /// /// - `authScheme`: The authentication scheme to use when authenticating the request. This should match the scheme used in your authentication configuration. /// - `handleOk`: The `HttpHandler` to invoke if the user is not authenticated. If the user is authenticated, a 403 Forbidden response will be returned. let ifNotAuthenticated (authScheme : string) (handleOk : HttpHandler) : HttpHandler = authenticate authScheme (fun authenticateResult ctx -> if authenticateResult.Succeeded then ctx.ForbidAsync() else handleOk ctx) ================================================ FILE: src/Falco/RequestData.fs ================================================ namespace Falco open System open System.Collections.Generic open Microsoft.AspNetCore.Http open Microsoft.FSharp.Core.Operators open Falco.StringPatterns module private RequestData = let epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc) let trueValues = HashSet([| "true"; "on"; "yes" |], StringComparer.OrdinalIgnoreCase) let falseValues = HashSet([| "false"; "off"; "no" |], StringComparer.OrdinalIgnoreCase) [] module private RequestValueExtensions = let tryGet name fn requestValue = match requestValue with | RObject props -> props |> List.tryFind (fun (k, _) -> String.Equals(k, name, StringComparison.OrdinalIgnoreCase)) |> Option.bind (fun (_, v) -> fn v) | _ -> None let orDefault maybe defaultValue opt = match opt, maybe with | Some x, _ -> x | None, Some v -> v | _ -> defaultValue let asOr asFn defaultValue requestValue = asFn requestValue |> Option.defaultWith (fun _ -> defaultValue) let bindList bind requestValue = match requestValue with | RList slist -> List.choose bind slist | RNull | RObject _ -> [] | v -> bind v |> Option.toList let tryGetOrElse name fn inputValue defaultValue requestValue = tryGet name fn requestValue |> Option.orElse inputValue |> Option.defaultValue defaultValue let tryGetList name fn requestValue = requestValue |> tryGet name (fn >> Some) |> Option.defaultValue [] let asObject requestValue = match requestValue with | RObject properties -> Some properties | _ -> None let asList requestValue = match requestValue with | RList a -> Some a | _ -> None let asRequestPrimitive requestValue = let rec asPrimitive requestValue = match requestValue with | RNull | RBool _ | RNumber _ | RString _ -> Some requestValue | RList lst -> List.tryHead lst |> Option.bind asPrimitive | _ -> None asPrimitive requestValue let asString requestValue = match asRequestPrimitive requestValue with | Some (RNull) -> Some "" | Some (RBool b) -> Some (if b then "true" else "false") | Some (RNumber n) -> Some (string n) | Some (RString s) -> Some s | _ -> None let asStringNonEmpty requestValue = match asRequestPrimitive requestValue with | Some (RBool b) -> Some (if b then "true" else "false") | Some (RNumber n) -> Some (string n) | Some (RString s) -> Some s | _ -> None let asInt16 requestValue = match asRequestPrimitive requestValue with | Some (RNumber x) when x >= float Int16.MinValue && x <= float Int16.MaxValue -> Some (Convert.ToInt16 x) | Some (RString x) -> StringParser.parseInt16 x | _ -> None let asInt32 requestValue = match asRequestPrimitive requestValue with | Some (RNumber x) when x >= float Int32.MinValue && x <= float Int32.MaxValue -> Some (Convert.ToInt32 x) | Some (RString x) -> StringParser.parseInt32 x | _ -> None let asInt64 requestValue = match asRequestPrimitive requestValue with | Some (RNumber x) when x >= float Int64.MinValue && x <= float Int64.MaxValue -> Some (Convert.ToInt64 x) | Some (RString x) -> StringParser.parseInt64 x | _ -> None let asBoolean requestValue = match asRequestPrimitive requestValue with | Some (RBool x) when x -> Some true | Some (RBool x) when not x -> Some false | Some (RNumber x) when x = 0. -> Some false | Some (RNumber x) when x = 1. -> Some true | Some (RString x) when RequestData.trueValues.Contains x -> Some true | Some (RString x) when RequestData.falseValues.Contains x -> Some false | _ -> None let asFloat requestValue = match asRequestPrimitive requestValue with | Some (RNumber x) -> Some x | Some (RString x) -> StringParser.parseFloat x | _ -> None let asDecimal requestValue = match asRequestPrimitive requestValue with | Some (RNumber x) -> Some (decimal x) | Some (RString x) -> StringParser.parseDecimal x | _ -> None let asDateTime requestValue = match asRequestPrimitive requestValue with | Some (RNumber n) when n >= float Int64.MinValue && n <= float Int64.MaxValue -> Some (RequestData.epoch.AddMilliseconds(n)) | Some (RString s) -> StringParser.parseDateTime s | _ -> None let asDateTimeOffset requestValue = match asRequestPrimitive requestValue with | Some (RNumber n) when n >= float Int64.MinValue && n <= float Int64.MaxValue -> Some (DateTimeOffset.FromUnixTimeMilliseconds(Convert.ToInt64 n)) | Some (RString s) -> StringParser.parseDateTimeOffset s | _ -> None let asTimeSpan requestValue = match asRequestPrimitive requestValue with | Some (RString s) -> StringParser.parseTimeSpan s | _ -> None let asGuid requestValue = match asRequestPrimitive requestValue with | Some (RString s) -> StringParser.parseGuid s | _ -> None let asStringList requestValue = bindList asString requestValue let asStringNonEmptyList requestValue = bindList asStringNonEmpty requestValue let asInt16List requestValue = bindList asInt16 requestValue let asInt32List requestValue = bindList asInt32 requestValue let asInt64List requestValue = bindList asInt64 requestValue let asBooleanList requestValue = bindList asBoolean requestValue let asFloatList requestValue = bindList asFloat requestValue let asDecimalList requestValue = bindList asDecimal requestValue let asDateTimeList requestValue = bindList asDateTime requestValue let asDateTimeOffsetList requestValue = bindList asDateTimeOffset requestValue let asTimeSpanList requestValue = bindList asTimeSpan requestValue let asGuidList requestValue = bindList asGuid requestValue type RequestData(requestValue : RequestValue) = new(requestData : IDictionary) = RequestData(RequestValue.parse requestData) new(keyValues : (string * string seq) seq) = RequestData(dict keyValues) static member Empty = RequestData RNull member _.TryGet(name : string) : RequestData option = tryGet name (RequestData >> Some) requestValue member _.Get(name : string) : RequestData = tryGet name (RequestData >> Some) requestValue |> Option.defaultValue RequestData.Empty member _.AsKeyValues() = asOr (asObject >> Option.map (List.map (fun (k, v) -> k, RequestData v))) [] requestValue member _.AsList() = asOr (asList >> Option.map (List.map RequestData)) [] requestValue member _.AsString ?defaultValue = requestValue |> asString |> orDefault defaultValue "" member _.AsStringNonEmpty ?defaultValue = requestValue |> asStringNonEmpty |> orDefault defaultValue "" member _.AsInt16 ?defaultValue = requestValue |> asInt16 |> orDefault defaultValue 0s member _.AsInt32 ?defaultValue = requestValue |> asInt32 |> orDefault defaultValue 0 member x.AsInt ?defaultValue = x.AsInt32(?defaultValue = defaultValue) member _.AsInt64 ?defaultValue = requestValue |> asInt64 |> orDefault defaultValue 0L member _.AsBoolean ?defaultValue = requestValue |> asBoolean |> orDefault defaultValue false member _.AsFloat ?defaultValue = requestValue |> asFloat |> orDefault defaultValue 0. member _.AsDecimal ?defaultValue = requestValue |> asDecimal |> orDefault defaultValue 0.M member _.AsDateTime ?defaultValue = requestValue |> asDateTime |> orDefault defaultValue DateTime.MinValue member _.AsDateTimeOffset ?defaultValue = requestValue |> asDateTimeOffset |> orDefault defaultValue DateTimeOffset.MinValue member _.AsTimeSpan ?defaultValue = requestValue |> asTimeSpan |> orDefault defaultValue TimeSpan.MinValue member _.AsGuid ?defaultValue = requestValue |> asGuid |> orDefault defaultValue Guid.Empty member _.AsStringOption() = asString requestValue member _.AsStringNonEmptyOption() = asStringNonEmpty requestValue member _.AsInt16Option() = asInt16 requestValue member _.AsInt32Option() = asInt32 requestValue member x.AsIntOption() = x.AsInt32Option() member _.AsInt64Option() = asInt64 requestValue member _.AsBooleanOption() = asBoolean requestValue member _.AsFloatOption() = asFloat requestValue member _.AsDecimalOption() = asDecimal requestValue member _.AsDateTimeOption() = asDateTime requestValue member _.AsDateTimeOffsetOption() = asDateTimeOffset requestValue member _.AsTimeSpanOption() = asTimeSpan requestValue member _.AsGuidOption() = asGuid requestValue member _.AsStringList() = asStringList requestValue member _.AsStringNonEmptyList() = asStringNonEmptyList requestValue member _.AsInt16List() = asInt16List requestValue member _.AsInt32List() = asInt32List requestValue member _.AsIntList() = asInt32List requestValue member _.AsInt64List() = asInt64List requestValue member _.AsBooleanList() = asBooleanList requestValue member _.AsFloatList() = asFloatList requestValue member _.AsDecimalList() = asDecimalList requestValue member _.AsDateTimeList() = asDateTimeList requestValue member _.AsDateTimeOffsetList() = asDateTimeOffsetList requestValue member _.AsGuidList() = asGuidList requestValue member _.AsTimeSpanList() = asTimeSpanList requestValue member _.TryGetString (name : string) = tryGet name asString requestValue member _.TryGetStringNonEmpty (name : string) = tryGet name asStringNonEmpty requestValue member _.TryGetInt16 (name : string) = tryGet name asInt16 requestValue member _.TryGetInt32 (name : string) = tryGet name asInt32 requestValue member _.TryGetInt (name : string) = tryGet name asInt32 requestValue member _.TryGetInt64 (name : string) = tryGet name asInt64 requestValue member _.TryGetBoolean (name : string) = tryGet name asBoolean requestValue member _.TryGetFloat (name : string) = tryGet name asFloat requestValue member _.TryGetDecimal (name : string) = tryGet name asDecimal requestValue member _.TryGetDateTime (name : string) = tryGet name asDateTime requestValue member _.TryGetDateTimeOffset (name : string) = tryGet name asDateTimeOffset requestValue member _.TryGetGuid (name : string) = tryGet name asGuid requestValue member _.TryGetTimeSpan (name : string) = tryGet name asTimeSpan requestValue member _.GetString (name : string, ?defaultValue : String) = tryGetOrElse name asString defaultValue "" requestValue member _.GetStringNonEmpty (name : string, ?defaultValue : String) = tryGetOrElse name asStringNonEmpty defaultValue "" requestValue member _.GetInt16 (name : string, ?defaultValue : Int16) = tryGetOrElse name asInt16 defaultValue 0s requestValue member _.GetInt32 (name : string, ?defaultValue : Int32) = tryGetOrElse name asInt32 defaultValue 0 requestValue member _.GetInt (name : string, ?defaultValue : Int32) = tryGetOrElse name asInt32 defaultValue 0 requestValue member _.GetInt64 (name : string, ?defaultValue : Int64) = tryGetOrElse name asInt64 defaultValue 0L requestValue member _.GetBoolean (name : string, ?defaultValue : Boolean) = tryGetOrElse name asBoolean defaultValue false requestValue member _.GetFloat (name : string, ?defaultValue : float) = tryGetOrElse name asFloat defaultValue 0 requestValue member _.GetDecimal (name : string, ?defaultValue : Decimal) = tryGetOrElse name asDecimal defaultValue 0M requestValue member _.GetDateTime (name : string, ?defaultValue : DateTime) = tryGetOrElse name asDateTime defaultValue DateTime.MinValue requestValue member _.GetDateTimeOffset (name : string, ?defaultValue : DateTimeOffset) = tryGetOrElse name asDateTimeOffset defaultValue DateTimeOffset.MinValue requestValue member _.GetGuid (name : string, ?defaultValue : Guid) = tryGetOrElse name asGuid defaultValue Guid.Empty requestValue member _.GetTimeSpan (name : string, ?defaultValue : TimeSpan) = tryGetOrElse name asTimeSpan defaultValue TimeSpan.MinValue requestValue member _.GetStringList (name : string) = tryGetList name asStringList requestValue member _.GetStringNonEmptyList (name : string) = tryGetList name asStringNonEmptyList requestValue member _.GetInt16List (name : string) = tryGetList name asInt16List requestValue member _.GetInt32List (name : string) = tryGetList name asInt32List requestValue member _.GetIntList (name : string) = tryGetList name asInt32List requestValue member _.GetInt64List (name : string) = tryGetList name asInt64List requestValue member _.GetBooleanList (name : string) = tryGetList name asBooleanList requestValue member _.GetFloatList (name : string) = tryGetList name asFloatList requestValue member _.GetDecimalList (name : string) = tryGetList name asDecimalList requestValue member _.GetDateTimeList (name : string) = tryGetList name asDateTimeList requestValue member _.GetDateTimeOffsetList (name : string) = tryGetList name asDateTimeOffsetList requestValue member _.GetGuidList (name : string) = tryGetList name asGuidList requestValue member _.GetTimeSpanList (name : string) = tryGetList name asTimeSpanList requestValue [] module RequestDataOperators = let inline (?) (requestData : RequestData) (name : string) = requestData.Get name [] type FormData(requestValue : RequestValue, files : IFormFileCollection option, ?isValid : bool) = inherit RequestData(requestValue) static member Empty = FormData(RequestValue.RNull, None, isValid = true) static member Invalid = FormData(RequestValue.RNull, None, isValid = false) /// Indicates whether the form passed antiforgery validation. member _.IsValid = defaultArg isValid true /// Indicates whether the form failed antiforgery validation. member x.IsInvalid = not x.IsValid /// The uploaded files included in the form, if any. member _.Files = files /// Attempts to retrieve a file from the form by name, returning `None` if /// the file is not found or if there are no files. member _.TryGetFile(name : string) = match files, name with | _, IsNullOrWhiteSpace _ | None, _ -> None | Some files, name -> match files.GetFile name with | f when isNull f -> None | f -> Some f ================================================ FILE: src/Falco/RequestValue.fs ================================================ namespace Falco open System open System.Collections.Generic open System.Net open System.Text.RegularExpressions open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Routing open Microsoft.FSharp.Core.Operators open Falco.StringPatterns type RequestValue = | RNull | RBool of bool | RNumber of float | RString of string | RList of elements : RequestValue list | RObject of keyValues : (string * RequestValue) list module internal RequestValueParser = let private (|IsFlatKey|_|) (x : string) = if not(x.EndsWith "[]") && not(x.Contains(".")) then Some x else None let private (|IsListKey|_|) (x : string) = if x.EndsWith "[]" then Some (x.Substring(0, x.Length - 2)) else None let private indexedListKeyRegex = Regex(@".\[(\d+)\]$", Text.RegularExpressions.RegexOptions.Compiled) let private (|IsIndexedListKey|_|) (x : string) = if x.EndsWith "]" then match indexedListKeyRegex.Match x with | m when m.Groups.Count = 2 -> let capture = m.Groups[1].Value Some (int capture, x.Substring(0, x.Length - capture.Length - 2)) | _ -> None else None let private extractRequestDataKeys (key : string, isSingle : bool) = key |> WebUtility.UrlDecode |> fun key -> key.Split('.', StringSplitOptions.RemoveEmptyEntries) |> List.ofArray |> function | [IsFlatKey key] when not isSingle ->[$"{key}[]"] | x -> x let private newRequestAcc () = Dictionary() let private requestAccToValues (x : Dictionary) = x |> Seq.map (fun (kvp) -> kvp.Key, kvp.Value) |> List.ofSeq |> RObject let private requestDatasToAcc (x : (string * RequestValue) list) = let acc = newRequestAcc() for key, value in x do acc.TryAdd(key, value) |> ignore acc let private parseRequestPrimitive (x : string) = let decoded = WebUtility.UrlDecode x match decoded with | IsNullOrWhiteSpace _ -> RNull | IsTrue x | IsFalse x -> RBool x // Don't parse integers with leading zeros (except "0" itself) as floats | _ when x.Length > 1 && x.StartsWith '0' && not(x.Contains '.') && not(x.Contains ',') -> RString x // Don't parse large numerics as floats | _ when x.Length > 15 -> RString x | IsFloat x -> RNumber x | x -> RString x let private parseRequestPrimitiveList values = values |> Seq.map parseRequestPrimitive |> List.ofSeq |> RList let private parseRequestPrimitiveSingle values = values |> Seq.tryHead |> Option.map parseRequestPrimitive |> Option.defaultValue RNull let private parseExistingRequestIndexedList index values (requestList : RequestValue list) = let requestArray = List.toArray requestList let lstAccLen = if index >= requestList.Length then index + 1 else requestList.Length let lstAcc : RequestValue array = Array.zeroCreate (lstAccLen) for i = 0 to lstAccLen - 1 do let lstRequestValue = if i <> index then match Array.tryItem i requestArray with | Some x -> x | None -> RNull else parseRequestPrimitiveSingle values lstAcc[i] <- lstRequestValue RList (List.ofArray lstAcc) let private parseRequestIndexedList index values = let lstAcc : RequestValue array = Array.zeroCreate (index + 1) for i = 0 to index do lstAcc[i] <- if i <> index then RNull else parseRequestPrimitiveSingle values RList (List.ofArray lstAcc) let parse (requestData : IDictionary) : RequestValue = let rec parseNested (acc : Dictionary) (keys : string list) (values : string seq) = match keys with | [] -> () | [IsListKey key] -> // list of primitives values |> parseRequestPrimitiveList |> fun x -> acc.TryAdd(key, x) |> ignore | [IsIndexedListKey (index, key)] -> // indexed list of primitives match acc.TryGetValue key with | true, RList requestList -> parseExistingRequestIndexedList index values requestList |> fun x -> acc[key] <- x | _ when index = 0 -> // first item in indexed list, initialize RList [ parseRequestPrimitiveSingle values ] |> fun x -> acc.TryAdd(key, x) |> ignore | _ -> parseRequestIndexedList index values |> fun x -> acc.TryAdd(key, x) |> ignore | [key] -> // primitive values |> parseRequestPrimitiveSingle |> fun x -> acc.TryAdd(key, x) |> ignore | IsListKey key :: remainingKeys -> // list of complex types match acc.TryGetValue key with | true, RList requestList -> requestList |> Seq.collect (fun requestData -> match requestData with | RObject requestObject -> let requestObjectAcc = requestDatasToAcc requestObject parseNested requestObjectAcc remainingKeys values Seq.singleton (requestObjectAcc |> requestAccToValues) | _ -> Seq.empty) |> List.ofSeq |> RList |> fun x -> acc[key] <- x | _ -> values |> Seq.map (fun value -> let listValueAcc = newRequestAcc() parseNested listValueAcc remainingKeys (seq { value }) listValueAcc |> requestAccToValues) |> List.ofSeq |> RList |> fun x -> acc.TryAdd(key, x) |> ignore | key :: remainingKeys -> // complex type match acc.TryGetValue key with | true, RObject requestObject -> let requestObjectAcc = requestDatasToAcc requestObject parseNested requestObjectAcc remainingKeys values acc[key] <- requestObjectAcc |> requestAccToValues | _ -> let requestObjectAcc = newRequestAcc() parseNested requestObjectAcc remainingKeys values acc.TryAdd(key, requestObjectAcc |> requestAccToValues) |> ignore let requestAcc = newRequestAcc() for kvp in requestData do let keys = extractRequestDataKeys (kvp.Key, Seq.tryItem 1 kvp.Value = None) parseNested requestAcc keys kvp.Value requestAcc |> requestAccToValues module RequestValue = /// Parses the provided request data into a `RequestValue` structure. /// /// - `requestData`: A dictionary where the key is a string representing the request data key (e.g., form field name, query parameter name) and the value is a sequence of strings representing the values associated with that key. This allows for handling multiple values for the same key, such as in the case of checkboxes or multi-select form fields. let parse (requestData : IDictionary) : RequestValue = RequestValueParser.parse requestData /// Parses a URL-encoded key-value string (e.g., "key1=value1&key2=value2") into a `RequestValue` structure. /// /// - `keyValueString`: A URL-encoded string containing key-value pairs, where keys and values are separated by '=' and pairs are separated by '&'. Keys without values (e.g., "key1&key2=value2") are treated as having an empty string value. The keys and values are URL-decoded before parsing. let parseString (keyValueString : string) : RequestValue = let requestDataPairs = Dictionary>() let addOrSet (acc : Dictionary>) key value = if acc.ContainsKey key then acc[key].Add value else acc.Add(key, List(Seq.singleton value)) () for kv in keyValueString.Split '&' do // Handle keys without values (e.g. "key1&key2=value2") by treating them as having an empty string value match kv.IndexOf '=' with | -1 -> addOrSet requestDataPairs kv String.Empty | idx -> let key = kv.Substring(0, idx) let value = if idx + 1 < kv.Length then kv.Substring(idx + 1) else String.Empty addOrSet requestDataPairs key value requestDataPairs |> Seq.map (fun kvp -> kvp.Key, kvp.Value :> IEnumerable) |> dict |> parse /// Parses the cookies from the request into a `RequestValue` structure. /// /// - `cookies`: An `IRequestCookieCollection` containing the cookies from the HTTP request. Each cookie's key and value are URL-decoded before parsing. Multiple cookies with the same key are not expected, as cookie keys are unique within a request, but if they do occur, they will be treated as multiple values for the same key. let parseCookies (cookies : IRequestCookieCollection) : RequestValue = cookies |> Seq.map (fun kvp -> kvp.Key, seq { kvp.Value }) |> dict |> parse /// Parses the headers from the request into a `RequestValue` structure. /// /// - `headers`: An `IHeaderDictionary` containing the headers from the HTTP request. Each header's key and value(s) are URL-decoded before parsing. Headers can have multiple values for the same key, and all values will be included in the resulting `RequestValue`. let parseHeaders (headers : IHeaderDictionary) : RequestValue = headers |> Seq.map (fun kvp -> kvp.Key, kvp.Value :> string seq) |> dict |> parse let private routeKeyValues (route : RouteValueDictionary) = route |> Seq.map (fun kvp -> kvp.Key, seq { Convert.ToString(kvp.Value, Globalization.CultureInfo.InvariantCulture) }) let private queryKeyValues (query : IQueryCollection) = query |> Seq.map (fun kvp -> kvp.Key, kvp.Value :> string seq) /// Parses the route values and query string from the request into a `RequestValue` structure. /// /// - `route`: A `RouteValueDictionary` containing the route values from the HTTP request. Each route value's key and value are URL-decoded before parsing. Route values are typically defined in the route template and can be used to capture dynamic segments of the URL. /// - `query`: An `IQueryCollection` containing the query string parameters from the HTTP request. Each query parameter's key and value(s) are URL-decoded before parsing. Query parameters can have multiple values for the same key, and all values will be included in the resulting `RequestValue`. let parseRoute (route : RouteValueDictionary, query : IQueryCollection) : RequestValue = Seq.concat [ route |> routeKeyValues query |> queryKeyValues ] |> dict |> parse /// Parses the query string from the request into a `RequestValue` structure. /// /// - `query`: An `IQueryCollection` containing the query string parameters from the HTTP request. Each query parameter's key and value(s) are URL-decoded before parsing. Query parameters can have multiple values for the same key, and all values will be included in the resulting `RequestValue`. let parseQuery (query : IQueryCollection) : RequestValue = query |> queryKeyValues |> dict |> parse /// Parses the form data from the request into a `RequestValue` structure. /// /// - `form`: An `IFormCollection` containing the form data from the HTTP request. Each form field's key and value(s) are URL-decoded before parsing. Form fields can have multiple values for the same key (e.g., in the case of checkboxes or multi-select fields), and all values will be included in the resulting `RequestValue`. The `route` parameter is optional and can be used to include route values in the parsing process, which is useful when you want to combine route values with form data. /// - `route`: An optional `RouteValueDictionary` containing the route values from the HTTP request. Each route value's key and value are URL-decoded before parsing. Route values are typically defined in the route template and can be used to capture dynamic segments of the URL. If provided, the route values will be combined with the form data during parsing, allowing you to access both sets of values in the resulting `RequestValue`. let parseForm (form : IFormCollection, route : RouteValueDictionary option) : RequestValue = let routeKeyValues = route |> Option.map routeKeyValues |> Option.defaultValue Seq.empty let formKeyValues = form |> Seq.map (fun kvp -> kvp.Key, kvp.Value :> string seq) Seq.concat [ routeKeyValues; formKeyValues ] |> dict |> parse ================================================ FILE: src/Falco/Response.fs ================================================ [] module Falco.Response open System open System.IO open System.Security.Claims open System.Text open System.Text.Json open Falco.Markup open Falco.Security open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Authentication open Microsoft.AspNetCore.Http open Microsoft.Extensions.Primitives open Microsoft.Net.Http.Headers // ------------ // Modifiers // ------------ /// Sets multiple headers for response. /// /// Headers provided will replace any existing headers with the same name. /// /// - `headers` - A list of header name and value pairs to add to the response. let withHeaders (headers : (string * string) list) : HttpResponseModifier = fun ctx -> headers |> List.iter (fun (name, content : string) -> ctx.Response.Headers[name] <- StringValues(content)) ctx /// Sets ContentType header for response. /// /// - `contentType` - The value to set for the Content-Type header. let withContentType (contentType : string) : HttpResponseModifier = fun ctx -> ctx.Response.ContentType <- contentType withHeaders [ HeaderNames.ContentType, contentType ] ctx /// Set StatusCode for response. /// /// - `statusCode` - The HTTP status code to set for the response. let withStatusCode (statusCode : int) : HttpResponseModifier = fun ctx -> ctx.Response.StatusCode <- statusCode ctx /// Adds cookie to response. /// /// - `key` - The name of the cookie to add to the response. /// - `value` - The value of the cookie to add to the response. let withCookie (key : string) (value : string) : HttpResponseModifier = fun ctx -> ctx.Response.Cookies.Append(key, value) ctx /// Adds a configured cookie to response, via CookieOptions. /// /// - `options` - The CookieOptions to apply when adding the cookie to the response. /// - `key` - The name of the cookie to add to the response. /// - `value` - The value of the cookie to add to the response. let withCookieOptions (options : CookieOptions) (key : string) (value : string) : HttpResponseModifier = fun ctx -> ctx.Response.Cookies.Append(key, value, options) ctx // ------------ // Handlers // ------------ /// Flushes any remaining response headers or data and returns empty response. let ofEmpty : HttpHandler = fun ctx -> ctx.Response.ContentLength <- 0 ctx.Response.CompleteAsync() type private RedirectType = | PermanentlyTo of url: string | TemporarilyTo of url: string let private redirect (redirectType: RedirectType): HttpHandler = fun ctx -> let (permanent, url) = match redirectType with | PermanentlyTo url -> (true, url) | TemporarilyTo url -> (false, url) task { ctx.Response.Redirect(url, permanent) do! ctx.Response.CompleteAsync() } /// Returns a redirect (301) to client. /// /// - `url` - The URL to which the client will be redirected. let redirectPermanently (url: string) = withStatusCode 301 >> redirect (PermanentlyTo url) /// Returns a redirect (302) to client. /// /// - `url` - The URL to which the client will be redirected. let redirectTemporarily (url: string) = withStatusCode 302 >> redirect (TemporarilyTo url) let private writeBytes (bytes : byte[]) : HttpHandler = fun ctx -> task { ctx.Response.ContentLength <- bytes.LongLength do! ctx.Response.Body.WriteAsync(bytes, 0, bytes.Length) } /// Returns an inline binary (i.e., Byte[]) response with the specified /// Content-Type. /// /// Note: Automatically sets "content-disposition: inline". /// /// - `contentType` - The value to set for the Content-Type header. /// - `headers` - A list of additional header name and value pairs to add to the response. /// - `bytes` - The binary content to write to the response body./// let ofBinary (contentType : string) (headers : (string * string) list) (bytes : Byte[]) : HttpHandler = let headers = (HeaderNames.ContentDisposition, "inline") :: headers withContentType contentType >> withHeaders headers >> writeBytes bytes /// Returns a binary (i.e., Byte[]) attachment response with the specified /// Content-Type and optional filename. /// /// Note: Automatically sets "content-disposition: attachment" and includes /// filename if provided. /// /// - `filename` - The name of the file to be used in the Content-Disposition header. If empty or null, no filename will be included. /// - `contentType` - The value to set for the Content-Type header. /// - `headers` - A list of additional header name and value pairs to add to the response. /// - `bytes` - The binary content to write to the response body. let ofAttachment (filename : string) (contentType : string) (headers : (string * string) list) (bytes : Byte[]) : HttpHandler = let contentDisposition = if StringUtils.strNotEmpty filename then let escapedFilename = HeaderUtilities.EscapeAsQuotedString filename StringUtils.strConcat [ "attachment; filename="; string escapedFilename ] else "attachment" let headers = (HeaderNames.ContentDisposition, contentDisposition) :: headers withContentType contentType >> withHeaders headers >> writeBytes bytes /// Writes string to response body with provided encoding. /// /// - `encoding` - The encoding to use when converting the string to bytes for the response body. /// - `str` - The string content to write to the response body. If null, empty, or whitespace, an empty response will be returned. let ofString (encoding : Encoding) (str : string) : HttpHandler = if String.IsNullOrWhiteSpace str then ofEmpty else writeBytes (encoding.GetBytes(str)) /// Returns a "text/plain; charset=utf-8" response with provided string to client. /// /// - `str` - The string content to write to the response body. If null, empty, or whitespace, an empty response will be returned. let ofPlainText (str : string) : HttpHandler = withContentType "text/plain; charset=utf-8" >> ofString Encoding.UTF8 str /// Returns a "text/html; charset=utf-8" response with provided HTML string to client. /// /// - `html` - The HTML string content to write to the response body. If null, empty, or whitespace, an empty response will be returned. let ofHtmlString (html : string) : HttpHandler = withContentType "text/html; charset=utf-8" >> ofString Encoding.UTF8 html /// Returns a "text/html; charset=utf-8" response with provided HTML to client. /// /// - `html` - The HTML content to write to the response body. If null, empty, or whitespace, an empty response will be returned. let ofHtml (html : XmlNode) : HttpHandler = ofHtmlString (renderHtml html) let private withCsrfToken handleToken : HttpHandler = fun ctx -> let csrfToken = Xsrf.getToken ctx handleToken csrfToken ctx /// Returns a CSRF token-dependant "text/html; charset=utf-8" response with /// provided HTML to client. /// /// - `view` - A function that takes an AntiforgeryTokenSet and returns the HTML content to write to the response body. If the returned HTML is null, empty, or whitespace, an empty response will be returned. let ofHtmlCsrf (view : AntiforgeryTokenSet -> XmlNode) : HttpHandler = withCsrfToken (fun token -> token |> view |> ofHtml) /// Returns a "text/html; charset=utf-8" response with provided HTML fragment, /// if found, to client. If no element with the provided id is found, an empty /// string is returned. /// /// - `id` - The id of the HTML element to render and write to the response body. /// - `html` - The HTML content to search for the element with the specified id. let ofFragment (id : string) (html : XmlNode) : HttpHandler = ofHtmlString (renderFragment html id) /// Returns a CSRF token-dependant "text/html; charset=utf-8" response with /// provided HTML fragment, if found, to client. If no element with the /// provided id is found, an empty string is returned. /// /// - `id` - The id of the HTML element to render and write to the response body. /// - `view` - A function that takes an AntiforgeryTokenSet and returns the HTML content to search for the element with the specified id. let ofFragmentCsrf (id : string) (view : AntiforgeryTokenSet -> XmlNode) : HttpHandler = withCsrfToken (fun token -> token |> view |> ofFragment id) /// Returns an optioned "application/json; charset=utf-8" response with the /// serialized object provided to the client. /// /// - `options` - The JsonSerializerOptions to use when serializing the object to JSON. /// - `obj` - The object to serialize to JSON and write to the response body. let ofJsonOptions (options : JsonSerializerOptions) (obj : 'T) : HttpHandler = withContentType "application/json; charset=utf-8" >> fun ctx -> task { use str = new MemoryStream() do! JsonSerializer.SerializeAsync(str, obj, options) ctx.Response.ContentLength <- str.Length str.Position <- 0 do! str.CopyToAsync ctx.Response.Body } /// Returns a "application/json; charset=utf-8" response with the serialized /// object provided to the client. /// /// - `obj` - The object to serialize to JSON and write to the response body. let ofJson (obj : 'T) : HttpHandler = withContentType "application/json; charset=utf-8" >> ofJsonOptions Request.defaultJsonOptions obj /// Signs in claim principal for provided scheme then responds with a 301 redirect /// to provided URL. /// /// - `authScheme` - The name of the authentication scheme to use when signing in the claim principal. /// - `claimsPrincipal` - The ClaimsPrincipal to sign in for the specified authentication scheme. let signIn (authScheme : string) (claimsPrincipal : ClaimsPrincipal) : HttpHandler = fun ctx -> task { do! ctx.SignInAsync(authScheme, claimsPrincipal) } /// Signs in claim principal for provided scheme and options then responds with a /// 301 redirect to provided URL (via AuthenticationProperties.RedirectUri). /// /// - `authScheme` - The name of the authentication scheme to use when signing in the claim principal. /// - `claimsPrincipal` - The ClaimsPrincipal to sign in for the specified authentication scheme. /// - `options` - The AuthenticationProperties to use when signing in the claim principal, which may include a RedirectUri for the post-sign-in redirect URL. let signInOptions (authScheme : string) (claimsPrincipal : ClaimsPrincipal) (options : AuthenticationProperties) : HttpHandler = withHeaders [ if not (String.IsNullOrEmpty options.RedirectUri) then HeaderNames.Location, options.RedirectUri ] >> (if not (String.IsNullOrEmpty options.RedirectUri) then withStatusCode 301 else id) >> fun ctx -> task { do! ctx.SignInAsync(authScheme, claimsPrincipal, options) } /// Signs in claim principal for provided scheme then responds with a 301 redirect /// to provided URL (via AuthenticationProperties.RedirectUri). /// /// - `authScheme` - The name of the authentication scheme to use when signing in the claim principal. /// - `claimsPrincipal` - The ClaimsPrincipal to sign in for the specified authentication scheme. /// - `url` - The URL to which the client will be redirected after signing in, which will be set in the AuthenticationProperties.RedirectUri. let signInAndRedirect (authScheme : string) (claimsPrincipal : ClaimsPrincipal) (url : string) : HttpHandler = let options = AuthenticationProperties(RedirectUri = url) signInOptions authScheme claimsPrincipal options /// Terminates authenticated context for provided scheme then responds with a 301 /// redirect to provided URL (via AuthenticationProperties.RedirectUri). /// /// - `authScheme` - The name of the authentication scheme to use when signing out the authenticated context. let signOut (authScheme : string) : HttpHandler = fun ctx -> task { do! ctx.SignOutAsync authScheme } /// Terminates authenticated context for provided scheme then responds with a 301 /// redirect to provided URL. /// /// - `authScheme` - The name of the authentication scheme to use when signing out the authenticated context. /// - `options` - The AuthenticationProperties to use when signing out, which may include a RedirectUri for the post-sign-out redirect URL. let signOutOptions (authScheme : string) (options : AuthenticationProperties) : HttpHandler = withHeaders [ if not (String.IsNullOrEmpty options.RedirectUri) then HeaderNames.Location, options.RedirectUri ] >> (if not (String.IsNullOrEmpty options.RedirectUri) then withStatusCode 301 else id) >> fun ctx -> task { do! ctx.SignOutAsync(authScheme, options) } /// Terminates authenticated context for provided scheme then responds with a 301 /// redirect to provided URL. /// /// - `authScheme` - The name of the authentication scheme to use when signing out the authenticated context. /// - `url` - The URL to which the client will be redirected after signing out, which will be set in the AuthenticationProperties.RedirectUri. let signOutAndRedirect (authScheme : string) (url : string) : HttpHandler = let options = AuthenticationProperties(RedirectUri = url) signOutOptions authScheme options /// Challenges the specified authentication scheme. /// An authentication challenge can be issued when an unauthenticated user /// requests an endpoint that requires authentication. Then given redirectUri is /// forwarded to the authentication handler for use after authentication succeeds. /// /// Note: If options.RedirectUri is provided, a 401 status code and Location header /// will be included in the response, with the Location header set to the RedirectUri. /// Otherwise, no status code or Location header will be included in the response. /// /// - `authScheme` - The name of the authentication scheme to challenge. /// - `options` - The AuthenticationProperties to use when challenging, which may include a RedirectUri for the post-challenge redirect URL. let challengeOptions (authScheme : string) (options : AuthenticationProperties) : HttpHandler = withStatusCode 401 >> withHeaders [ HeaderNames.WWWAuthenticate, authScheme if not (String.IsNullOrEmpty options.RedirectUri) then HeaderNames.Location, options.RedirectUri ] >> fun ctx -> task { do! ctx.ChallengeAsync(authScheme, options) } /// Challenges the specified authentication scheme. /// An authentication challenge can be issued when an unauthenticated user /// requests an endpoint that requires authentication. Then given redirectUri is /// forwarded to the authentication handler for use after authentication succeeds. /// /// Note: A 401 status code and Location header will be included in the response, with the Location header set to the provided redirectUri. /// /// - `authScheme` - The name of the authentication scheme to challenge. /// - `redirectUri` - The URL to which the client will be redirected after the challenge, which will be set in the AuthenticationProperties.RedirectUri. let challengeAndRedirect (authScheme : string) (redirectUri : string) : HttpHandler = let options = AuthenticationProperties(RedirectUri = redirectUri) challengeOptions authScheme options ================================================ FILE: src/Falco/Routing.fs ================================================ namespace Falco open System open System.Collections.Generic open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Routing open Microsoft.Extensions.FileProviders open Falco.StringUtils /// Specifies an association of a route pattern to a collection of /// HttpEndpointHandler. type HttpEndpoint = { Pattern : string Handlers : (HttpVerb * HttpHandler) seq Configure : EndpointBuilder -> EndpointBuilder } module Routing = /// Constructor for multi-method HttpEndpoint. /// /// - `pattern` - The route pattern to which the HttpEndpoint will be associated. /// - `handlers` - A sequence of tuples associating an HttpVerb to an HttpHandler. The HttpVerb ANY can be used to match any HTTP method. let all (pattern : string) (handlers : (HttpVerb * HttpHandler) seq) : HttpEndpoint = { Pattern = pattern Handlers = handlers Configure = id } /// Constructor for a singular HttpEndpoint. /// /// - `verb` - The HttpVerb to which the HttpHandler will be associated. The HttpVerb ANY can be used to match any HTTP method. /// - `pattern` - The route pattern to which the HttpEndpoint will be associated. /// - `handler` - The HttpHandler to be associated with the provided HttpVerb and route pattern. let route verb pattern handler = all pattern [ verb, handler ] /// HttpEndpoint constructor that matches any HttpVerb. /// /// Note: Use with caution as this will match any HTTP method, which may not be desirable in all cases. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. The HttpVerb ANY can be used to match any HTTP method. /// - `handler` - The HttpHandler to be associated with the provided route pattern for any HTTP method. let any pattern handler = route ANY pattern handler /// GET HttpEndpoint constructor. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the GET HttpVerb and provided route pattern. let get pattern handler = route GET pattern handler /// HEAD HttpEndpoint constructor. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the HEAD HttpVerb and provided route pattern. let head pattern handler = route HEAD pattern handler /// POST HttpEndpoint constructor. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the POST HttpVerb and provided route pattern. let post pattern handler = route POST pattern handler /// PUT HttpEndpoint constructor. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the PUT HttpVerb and let put pattern handler = route PUT pattern handler /// PATCH HttpEndpoint constructor. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the PATCH HttpVerb and let patch pattern handler = route PATCH pattern handler /// DELETE HttpEndpoint constructor. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the DELETE HttpVerb and provided route pattern. let delete pattern handler = route DELETE pattern handler /// OPTIONS HttpEndpoint constructor. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the OPTIONS HttpVerb and provided route pattern. let options pattern handler = route OPTIONS pattern handler /// TRACE HttpEndpoint construct. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `handler` - The HttpHandler to be associated with the TRACE HttpVerb and provided route pattern. let trace pattern handler = route TRACE pattern handler /// HttpEndpoint constructor that matches any HttpVerb which maps the route /// using the provided `map` function. /// /// Note: Use with caution as this will match any HTTP method, which may not be desirable in all cases. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. The HttpVerb ANY can be used to match any HTTP method. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the provided route pattern for any HTTP method, which will receive the mapped value in the HttpContext. let mapAny pattern map handler = any pattern (Request.mapRoute map handler) /// GET HttpEndpoint constructor which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the GET HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapGet pattern map handler = get pattern (Request.mapRoute map handler) /// HEAD HttpEndpoint constructor which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the HEAD HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapHead pattern map handler = head pattern (Request.mapRoute map handler) /// POST HttpEndpoint constructor which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the POST HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapPost pattern map handler = post pattern (Request.mapRoute map handler) /// PUT HttpEndpoint constructor which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the PUT HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapPut pattern map handler = put pattern (Request.mapRoute map handler) /// PATCH HttpEndpoint constructor which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the PATCH HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapPatch pattern map handler = patch pattern (Request.mapRoute map handler) /// DELETE HttpEndpoint constructor which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the DELETE HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapDelete pattern map handler = delete pattern (Request.mapRoute map handler) /// OPTIONS HttpEndpoint constructor which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the OPTIONS HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapOptions pattern map handler = options pattern (Request.mapRoute map handler) /// TRACE HttpEndpoint construct which maps the route using the provided /// `map` function. /// /// - `pattern` - The route pattern to which the HttpHandler will be associated. /// - `map` - A function that takes the route pattern and returns a mapped value which will be included in the HttpContext for the HttpHandler to use. /// - `handler` - The HttpHandler to be associated with the TRACE HttpVerb and provided route pattern, which will receive the mapped value in the HttpContext. let mapTrace pattern map handler = trace pattern (Request.mapRoute map handler) /// Configure the display name attribute of the endpoint. /// /// Note: The display name is used for endpoint selection and will be included /// in the HttpContext for the HttpHandler to use. /// /// - `displayName` - The display name to be associated with the endpoint, which will be included in the HttpContext for the HttpHandler to use. /// - `endpoint` - The HttpEndpoint for which the display name will be set. let setDisplayName (displayName : string) (endpoint : HttpEndpoint) = let configure (builder : EndpointBuilder) = (builder :?> RouteEndpointBuilder).DisplayName <- displayName builder { endpoint with Configure = endpoint.Configure >> configure } /// Set an explicit order for the endpoint. /// /// Note: The order is used for endpoint selection and will be included in the /// HttpContext for the HttpHandler to use. Endpoints with lower order values /// will be selected before those with higher values. /// /// - `n` - The order to be associated with the endpoint, which will be included in the HttpContext for the HttpHandler to use. /// - `endpoint` - The HttpEndpoint for which the order will be set. let setOrder (n : int) (endpoint : HttpEndpoint) = let configure (builder : EndpointBuilder) = (builder :?> RouteEndpointBuilder).Order <- n builder { endpoint with Configure = endpoint.Configure >> configure } [] type FalcoEndpointDataSource(httpEndpoints : HttpEndpoint seq) = inherit EndpointDataSource() let conventions = List>() new() = FalcoEndpointDataSource([]) member val FalcoEndpoints = List() override x.Endpoints with get() = x.BuildEndpoints() override _.GetChangeToken() = NullChangeToken.Singleton member private this.BuildEndpoints () = let endpoints = List() for endpoint in Seq.concat [ httpEndpoints; this.FalcoEndpoints ] do let routePattern = Patterns.RoutePatternFactory.Parse endpoint.Pattern for (verb, handler) in endpoint.Handlers do let verbStr = verb.ToString() let displayName = if strEmpty verbStr then endpoint.Pattern else strConcat [|verbStr; " "; endpoint.Pattern|] let endpointBuilder = RouteEndpointBuilder( requestDelegate = HttpHandler.toRequestDelegate handler, routePattern = routePattern, order = 0, DisplayName = displayName) endpointBuilder.DisplayName <- displayName endpoint.Configure endpointBuilder |> ignore for convention in conventions do convention.Invoke(endpointBuilder) let routeNameMetadata = RouteNameMetadata(endpoint.Pattern) endpointBuilder.Metadata.Add(routeNameMetadata) let httpMethodMetadata = match verb with | ANY -> HttpMethodMetadata [||] | _ -> HttpMethodMetadata [|verbStr|] endpointBuilder.Metadata.Add(httpMethodMetadata) endpoints.Add(endpointBuilder.Build()) endpoints interface IEndpointConventionBuilder with member _.Add(convention: Action) : unit = conventions.Add(convention) member _.Finally (_: Action): unit = () ================================================ FILE: src/Falco/Security.fs ================================================ module Falco.Security module Xsrf = open System open System.Threading.Tasks open Falco.Markup open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Http open Microsoft.Extensions.DependencyInjection /// Outputs an antiforgery . /// /// This should be used within a form to include the antiforgery token as part /// of the form submission. The token is generated and stored in the user's /// cookies by calling `getToken` and then passed to this function to create /// the hidden input field. /// /// - `token` - The antiforgery token set containing the form field name and request token value. let antiforgeryInput (token : AntiforgeryTokenSet) = Elem.input [ Attr.type' "hidden" Attr.name token.FormFieldName Attr.value token.RequestToken ] /// Generates an antiforgery token and stores it in the user's cookies. let getToken (ctx : HttpContext) : AntiforgeryTokenSet = let antiFrg = ctx.RequestServices.GetRequiredService() antiFrg.GetAndStoreTokens ctx /// Validates the antiforgery token within the provided HttpContext. let validateToken (ctx : HttpContext) : Task = if ctx.Request.Method = HttpMethods.Get || ctx.Request.Method = HttpMethods.Options || ctx.Request.Method = HttpMethods.Head || ctx.Request.Method = HttpMethods.Trace then Task.FromResult true else task { try let antiFrg = ctx.RequestServices.GetRequiredService() do! antiFrg.ValidateRequestAsync ctx return true with | :? InvalidOperationException -> return true // Antiforgery not registered, consider valid | :? AntiforgeryValidationException -> return false // Token present but invalid } ================================================ FILE: src/Falco/String.fs ================================================ namespace Falco open System open System.Collections.Generic open System.Globalization module internal StringUtils = /// Checks if string is null or whitespace. let strEmpty str = String.IsNullOrWhiteSpace(str) /// Checks if string is not null or whitespace. let strNotEmpty str = not(strEmpty str) /// Case & culture insensitive string equality. let strEquals s1 s2 = String.Equals(s1, s2, StringComparison.InvariantCultureIgnoreCase) /// Concats strings. let strConcat (lst : string seq) = // String.Concat uses a StringBuilder when provided an IEnumerable // Url: https://github.com/microsoft/referencesource/blob/master/mscorlib/system/string.cs#L161 String.Concat(lst) /// Splits string into substrings based on separator. let strSplit (sep : char array) (str : string) = str.Split(sep, StringSplitOptions.RemoveEmptyEntries) module internal StringParser = /// Helper to wrap .NET tryParser's. let private tryParseWith (tryParseFunc: string -> bool * _) (str : string) = let parsedResult = tryParseFunc str match parsedResult with | true, v -> Some v | false, _ -> None let parseBoolean (value : string) = // we explicitly do not support on/off boolean values // see: https://github.com/falcoframework/Falco/issues/129#issuecomment-2496081776 match value with | x when String.Equals("true", x, StringComparison.OrdinalIgnoreCase) -> Some true | x when String.Equals("false", x, StringComparison.OrdinalIgnoreCase) -> Some false | v -> tryParseWith Boolean.TryParse v let parseInt16 = tryParseWith Int16.TryParse let parseInt64 = tryParseWith Int64.TryParse let parseInt32 = tryParseWith Int32.TryParse let parseFloat = tryParseWith Double.TryParse let parseDecimal = tryParseWith (fun x -> Decimal.TryParse(x, NumberStyles.Number ||| NumberStyles.AllowExponent ||| NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture)) let parseDateTime = tryParseWith (fun x -> DateTime.TryParse(x, null, DateTimeStyles.RoundtripKind)) let parseDateTimeOffset = tryParseWith (fun x -> DateTimeOffset.TryParse(x, null, DateTimeStyles.RoundtripKind)) let parseTimeSpan = tryParseWith TimeSpan.TryParse let parseGuid = tryParseWith Guid.TryParse module internal StringPatterns = let (|IsBool|_|) = StringParser.parseBoolean let (|IsInt16|_|) = StringParser.parseInt16 let (|IsInt64|_|) = StringParser.parseInt64 let (|IsInt32|_|) = StringParser.parseInt32 let (|IsFloat|_|) (x : string) = StringParser.parseFloat x let (|IsDecimal|_|) = StringParser.parseDecimal let (|IsDateTime|_|) = StringParser.parseDateTime let (|IsDateTimeOffset|_|) = StringParser.parseDateTimeOffset let (|IsTimeSpan|_|) = StringParser.parseTimeSpan let (|IsGuid|_|) = StringParser.parseGuid let (|IsTrue|_|) = function | IsBool x when x = true -> Some true | _ -> None let (|IsFalse|_|) = function | IsBool x when x = false -> Some false | _ -> None let (|IsNullOrWhiteSpace|_|) (x : string) = match String.IsNullOrWhiteSpace x with | true -> Some () | false -> None ================================================ FILE: src/Falco/WebApplication.fs ================================================ namespace Falco [] module Extensions = open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Routing open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Logging type IEndpointRouteBuilder with /// Registers a sequence of `Falco.HttpEndpoint` with the endpoint route builder. member this.UseFalcoEndpoints(endpoints : HttpEndpoint seq) : IEndpointRouteBuilder = let falcoDataSource = let registeredDataSource = this.ServiceProvider.GetService() if obj.ReferenceEquals(registeredDataSource, null) then FalcoEndpointDataSource([]) else registeredDataSource for endpoint in endpoints do falcoDataSource.FalcoEndpoints.Add(endpoint) this.DataSources.Add(falcoDataSource) this type WebApplicationBuilder with member this.AddConfiguration(fn : IConfigurationBuilder -> IConfigurationBuilder) : WebApplicationBuilder = fn this.Configuration |> ignore this member this.AddLogging(fn : ILoggingBuilder -> ILoggingBuilder) : WebApplicationBuilder = fn this.Logging |> ignore this /// Apply `fn` to `WebApplicationBuilder.Services :> IServiceCollection` if `predicate` is true. member this.AddServicesIf(predicate : bool, fn : IConfiguration -> IServiceCollection -> IServiceCollection) : WebApplicationBuilder = if predicate then fn this.Configuration this.Services |> ignore this member this.AddServices(fn : IConfiguration -> IServiceCollection -> IServiceCollection) : WebApplicationBuilder = this.AddServicesIf(true, fn) type IApplicationBuilder with /// Apply `fn` to `WebApplication :> IApplicationBuilder` if `predicate` is true. member this.UseIf(predicate : bool, fn : IApplicationBuilder -> IApplicationBuilder) : IApplicationBuilder = if predicate then fn this |> ignore this /// Analagous to `IApplicationBuilder.Use` but returns `WebApplication`. member this.Use(fn : IApplicationBuilder -> IApplicationBuilder) : IApplicationBuilder = this.UseIf(true, fn) /// Activates Falco integration with IEndpointRouteBuilder. /// /// This is the default way to enable the library. member this.UseFalco(endpoints : HttpEndpoint seq) : IApplicationBuilder = this.UseEndpoints(fun endpointBuilder -> endpointBuilder.UseFalcoEndpoints(endpoints) |> ignore) /// Registers a `Falco.HttpHandler` as terminal middleware (i.e., not found). /// This should be registered at the end of the middleware pipeline to catch any /// requests that were not handled by any other middleware. The provided /// handler will be executed for any requests that reach this point in /// the pipeline, allowing you to return a custom 404 response or perform /// other actions as needed. member this.UseFalcoNotFound(notFoundHandler : HttpHandler) : unit = this.Run(handler = HttpHandler.toRequestDelegate notFoundHandler) /// Registers a `Falco.HttpHandler` as exception handler lambda. /// See: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?#exception-handler-lambda member this.UseFalcoExceptionHandler(exceptionHandler : HttpHandler) : IApplicationBuilder = let configure (appBuilder : IApplicationBuilder) = appBuilder.Run(HttpHandler.toRequestDelegate exceptionHandler) this.UseExceptionHandler(configure) |> ignore this type WebApplication with /// Registers a `Falco.HttpHandler` as terminal middleware (i.e., not found) /// then runs application, blocking the calling thread until host shutdown. member this.Run(terminalHandler : HttpHandler) : unit = this.UseFalcoNotFound(terminalHandler) |> ignore this.Run() /// Apply `fn` to `WebApplication :> IApplicationBuilder` if `predicate` is true. member this.UseIf(predicate : bool, fn : IApplicationBuilder -> IApplicationBuilder) : WebApplication = (this :> IApplicationBuilder).UseIf(predicate, fn) |> ignore this /// Analagous to `IApplicationBuilder.Use` but returns `WebApplication`. member this.Use(fn : IApplicationBuilder -> IApplicationBuilder) : WebApplication = this.UseIf(true, fn) member this.UseRouting() : WebApplication = (this :> IApplicationBuilder).UseRouting() |> ignore this /// Activates Falco integration with IEndpointRouteBuilder. /// /// This is the default way to enable the package. member this.UseFalco(endpoints : HttpEndpoint seq) : WebApplication = (this :> IApplicationBuilder).UseFalco(endpoints) |> ignore this /// Registers a `Falco.HttpHandler` as exception handler lambda. /// See: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?#exception-handler-lambda member this.UseFalcoExceptionHandler(exceptionHandler : HttpHandler) : WebApplication = (this :> IApplicationBuilder).UseFalcoExceptionHandler(exceptionHandler) |> ignore this /// Registers a `Falco.HttpHandler` as terminal middleware (i.e., not found). member this.UseFalcoNotFound(notFoundHandler : HttpHandler) : WebApplication = (this :> IApplicationBuilder).UseFalcoNotFound(notFoundHandler) |> ignore this type FalcoExtensions = /// Registers a `Falco.HttpHandler` as global exception handler. static member UseFalcoExceptionHandler (exceptionHandler : HttpHandler) (app : IApplicationBuilder) = app.UseFalcoExceptionHandler exceptionHandler type HttpContext with /// Attempts to obtain dependency from IServiceCollection /// Throws InvalidDependencyException on missing. member this.Plug<'T>() = this.RequestServices.GetRequiredService<'T>() ================================================ FILE: test/Falco.IntegrationTests/Falco.IntegrationTests.fsproj ================================================ net10.0 false false all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: test/Falco.IntegrationTests/Program.fs ================================================ namespace Falco.IntegrationTests open System.Net.Http open System.Text open System.Text.Json open Microsoft.AspNetCore.Mvc.Testing open Xunit open Falco.IntegrationTests.App module FalcoOpenApiTestServer = let createFactory() = new WebApplicationFactory() module Tests = let private factory = FalcoOpenApiTestServer.createFactory () [] let ``Receive plain-text response from: GET /hello``() = use client = factory.CreateClient () let content = client.GetStringAsync("/").Result Assert.Equal("Hello World!", content) [] let ``Receive text/html response from GET /html`` () = use client = factory.CreateClient () let content = client.GetStringAsync("/html").Result Assert.Equal("""

hello world

""", content) [] let ``Receive application/json response from GET /json`` () = use client = factory.CreateClient () let content = client.GetStringAsync("/json").Result Assert.Equal("""{"Message":"hello world"}""", content) [] let ``Receive mapped application/json response from: GET /hello/name?`` () = use client = factory.CreateClient () let content = client.GetStringAsync("/hello").Result Assert.Equal("""{"Message":"Hello world!"}""", content) let content = client.GetStringAsync("/hello/John").Result Assert.Equal("""{"Message":"Hello John!"}""", content) let content = client.GetStringAsync("/hello/John?age=42").Result Assert.Equal("""{"Message":"Hello John, you are 42 years old!"}""", content) [] let ``Receive mapped application/json response from: POST /hello/name?`` () = use client = factory.CreateClient () use form = new FormUrlEncodedContent([]) let response = client.PostAsync("/hello", form).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{"Message":"Hello world!"}""", content) let response = client.PostAsync("/hello/John", form).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{"Message":"Hello John!"}""", content) use form = new FormUrlEncodedContent(dict [ ("age", "42") ]) let response = client.PostAsync("/hello/John", form).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{"Message":"Hello John, you are 42 years old!"}""", content) [] let ``Receive utf8 text/plain response from: GET /plug/name?`` () = use client = factory.CreateClient () let content = client.GetStringAsync("/plug").Result Assert.Equal("Hello world 😀", content) let content = client.GetStringAsync("/plug/John").Result Assert.Equal("Hello John 😀", content) [] let ``Receive application/json request body and return from: GET /api/message`` () = use client = factory.CreateClient () use body = new StringContent(JsonSerializer.Serialize { Message = "Hello /api/message" }, Encoding.UTF8, "application/json") let response = client.PostAsync("/api/message", body).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{"Message":"Hello /api/message"}""", content) use body = new StringContent("", Encoding.UTF8, "application/json") let response = client.PostAsync("/api/message", body).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{}""", content) [] let ``GET /hello/ (trailing slash) returns default greeting`` () = use client = factory.CreateClient() let content = client.GetStringAsync("/hello/").Result Assert.Equal("""{"Message":"Hello world!"}""", content) [] let ``GET /hello/{name} decodes url-encoded name`` () = use client = factory.CreateClient() let content = client.GetStringAsync("/hello/John%20Doe").Result Assert.Equal("""{"Message":"Hello John Doe!"}""", content) [] let ``GET /hello/{name}?age=invalid ignores invalid age`` () = use client = factory.CreateClient() let content = client.GetStringAsync("/hello/John?age=not-a-number").Result Assert.Equal("""{"Message":"Hello John!"}""", content) [] let ``POST /hello/{name} with invalid age ignores age`` () = use client = factory.CreateClient() use form = new FormUrlEncodedContent(dict [ ("age", "not-a-number") ]) let response = client.PostAsync("/hello/Jane", form).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{"Message":"Hello Jane!"}""", content) [] let ``POST /hello with age but no name uses default world`` () = use client = factory.CreateClient() use form = new FormUrlEncodedContent(dict [ ("age", "7") ]) let response = client.PostAsync("/hello", form).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{"Message":"Hello world, you are 7 years old!"}""", content) [] let ``POST /api/message with non-json content type should fail`` () = use client = factory.CreateClient() use body = new StringContent("Message=Hello", Encoding.UTF8, "text/plain") let response = client.PostAsync("/api/message", body).Result Assert.False(response.IsSuccessStatusCode) [] let ``POST /api/message echoes input`` () = use client = factory.CreateClient() let payload = """{"Message":"Hello /api/message","Extra":"ignored"}""" use body = new StringContent(payload, Encoding.UTF8, "application/json") let response = client.PostAsync("/api/message", body).Result let content = response.Content.ReadAsStringAsync().Result Assert.Equal("""{"Message":"Hello /api/message","Extra":"ignored"}""", content) [] let ``GET /json returns application/json content type`` () = use client = factory.CreateClient() let response = client.GetAsync("/json").Result let ct = response.Content.Headers.ContentType.ToString() Assert.Contains("application/json", ct) [] let ``GET /hello returns application/json content type`` () = use client = factory.CreateClient() let response = client.GetAsync("/hello").Result let ct = response.Content.Headers.ContentType.ToString() Assert.Contains("application/json", ct) [] let ``GET / returns text/plain content type`` () = use client = factory.CreateClient() let response = client.GetAsync("/").Result let ct = response.Content.Headers.ContentType.ToString() Assert.Contains("text/plain", ct) module Program = let [] main _ = 0 ================================================ FILE: test/Falco.IntegrationTests.App/Falco.IntegrationTests.App.fsproj ================================================ net10.0 ================================================ FILE: test/Falco.IntegrationTests.App/Program.fs ================================================ module Falco.IntegrationTests.App open Falco open Falco.Markup open Falco.Routing open Microsoft.AspNetCore.Builder open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Hosting type IGreeter = abstract member Greet : name : string -> string type FriendlyGreeter() = interface IGreeter with member _.Greet(name : string) = $"Hello {name} 😀" type Person = { Name : string Age : int option } type Greeting = { Message : string } let endpoints = let mapRouteData (data : RequestData) = { Name = data?name.AsStringNonEmpty("world") Age = None } let mapRequestData (person : Person) (data : RequestData) = let person = { person with Age = data?age.AsIntOption() } let message = match person.Age with | Some a -> $"Hello {person.Name}, you are {a} years old!" | _ -> $"Hello {person.Name}!" { Message = message } [ get "/" (Response.ofPlainText "Hello World!") get "/html" (Response.ofHtml (Elem.html [] [ Elem.head [] [] Elem.body [] [ Text.h1 "hello world" ] ])) get "/json" (Response.ofJson { Message = "hello world" }) mapGet "/hello/{name?}" mapRouteData (fun person -> Request.mapQuery (mapRequestData person) Response.ofJson) mapPost "/hello/{name?}" mapRouteData (fun person -> Request.mapForm (mapRequestData person) Response.ofJson) mapGet "/plug/{name?}" (fun r -> r?name.AsStringNonEmpty("world")) (fun name ctx -> let greeter = ctx.Plug() // <-- access our dependency from the container let greeting = greeter.Greet(name) // <-- invoke our greeter.Greet(name) method Response.ofPlainText greeting ctx) post "/api/message" (Request.mapJson Response.ofJson) ] let bldr = WebApplication.CreateBuilder() bldr.Services .AddSingleton() |> ignore let wapp = bldr.Build() wapp.UseHttpsRedirection() |> ignore wapp.UseRouting() .UseFalco(endpoints) |> ignore wapp.Run() type Program() = class end ================================================ FILE: test/Falco.Tests/Common.fs ================================================ [] module Falco.Tests.Common #nowarn "44" open System open System.IO open System.IO.Pipelines open System.Security.Claims open System.Threading.Tasks open FsUnit.Xunit open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Authentication open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Routing open Microsoft.Extensions.DependencyInjection open Microsoft.Net.Http.Headers open NSubstitute open System.Collections.Generic let shouldBeSome pred (option : Option<'a>) = match option with | Some o -> pred o | None -> sprintf "Should not be None" |> should equal false let shouldBeNone (option : Option<'a>) = match option with | Some o -> sprintf "Should not be Some" |> should equal false | None -> () [] type FakeRecord = { Name : string } let getResponseBody (ctx : HttpContext) = task { ctx.Response.Body.Position <- 0L use reader = new StreamReader(ctx.Response.Body) return! reader.ReadToEndAsync() } [] let AuthScheme = "Testing" let CookieScheme = CookieAuthenticationDefaults.AuthenticationScheme let AuthRoles = ["admin"; "user"] let getHttpContextWriteable (authenticated : bool) = let ctx = Substitute.For() let req = Substitute.For() req.Headers.Returns(Substitute.For()) |> ignore req.RouteValues.Returns(Substitute.For()) |> ignore let resp = Substitute.For() resp.Headers.Returns(Substitute.For()) |> ignore let respBody = new MemoryStream() resp.BodyWriter.Returns(PipeWriter.Create respBody) |> ignore resp.Body <- respBody let antiforgery = Substitute.For() antiforgery.GetAndStoreTokens(Arg.Any()).Returns( AntiforgeryTokenSet("requestToken", "cookieToken", "formFieldName", "headerName") ) |> ignore antiforgery.IsRequestValidAsync(ctx).Returns(Task.FromResult(true)) |> ignore let authService = Substitute.For() let claims = AuthRoles |> List.map (fun role -> Claim(ClaimTypes.Role, role)) let identity = ClaimsIdentity(claims, AuthScheme) let principal = ClaimsPrincipal identity let authResult = if authenticated then AuthenticateResult.Success(AuthenticationTicket(principal, AuthScheme)) else AuthenticateResult.NoResult() authService.AuthenticateAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(authResult)) |> ignore authService.SignInAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask) |> ignore authService.SignOutAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask) |> ignore authService.ChallengeAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(fun args -> let ctx = args.Arg() let scheme = args.Arg() Task.CompletedTask ) |> ignore let serviceCollection = ServiceCollection() serviceCollection .AddLogging() .AddAuthorization() .AddSingleton(authService) .AddSingleton(antiforgery) |> ignore if authenticated then serviceCollection .AddAuthentication(AuthScheme) .AddCookie(CookieScheme) else serviceCollection .AddAuthentication(AuthScheme) .AddCookie(CookieScheme) |> ignore let provider = serviceCollection.BuildServiceProvider() ctx.Request.Returns req |> ignore ctx.Response.Returns resp |> ignore ctx.RequestServices .GetService(Arg.Any()) .Returns(fun args -> let serviceType = args.Arg() provider.GetService(serviceType) ) |> ignore ctx let cookieCollection cookies = { new IRequestCookieCollection with member __.ContainsKey(key: string) = Map.containsKey key cookies member __.Count = Map.count cookies member __.GetEnumerator() = (Map.toSeq cookies |> Seq.map KeyValuePair).GetEnumerator() member __.GetEnumerator() = __.GetEnumerator() :> Collections.IEnumerator member __.Item with get (key: string): string = Map.find key cookies member __.Keys = Map.toSeq cookies |> Seq.map fst |> ResizeArray :> Collections.Generic.ICollection member __.TryGetValue(key: string, value: byref): bool = match Map.tryFind key cookies with | Some _ -> true | _ -> false } ================================================ FILE: test/Falco.Tests/Falco.Tests.fsproj ================================================ net10.0 false false all runtime; build; native; contentfiles; analyzers; buildtransitive all runtime; build; native; contentfiles; analyzers; buildtransitive ================================================ FILE: test/Falco.Tests/MultipartTests.fs ================================================ module Falco.Tests.Multipart open System open System.IO open System.Text open System.Threading open System.Threading.Tasks open Falco open Falco.Multipart open FsUnit.Xunit open Xunit open Microsoft.AspNetCore.WebUtilities open Microsoft.Extensions.Primitives [] let ``MultipartReader.StreamSectionsAsync()`` () = let onePartBody = "--9051914041544843365972754266\r\n" + "Content-Disposition: form-data; name=\"name\"\r\n" + "\r\n" + "falco\r\n" + "--9051914041544843365972754266--\r\n"; use body = new MemoryStream(Encoding.UTF8.GetBytes(onePartBody)) let rd = new MultipartReader("9051914041544843365972754266", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) // 10mb max file size form.Files.Count |> should equal 0 let formData = FormData(RequestValue.parseForm(form, None), Some form.Files) let requestValue = formData?name.AsString() requestValue |> should equal "falco" } [] let ``MultipartReader.StreamSectionsAsync() with 3-part body`` () = let threePartBody = "--9051914041544843365972754266\r\n" + "Content-Disposition: form-data; name=\"name\"\r\n" + "\r\n" + "falco\r\n" + "--9051914041544843365972754266\r\n" + "Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Content of a.txt.\r\n" + "\r\n" + "--9051914041544843365972754266\r\n" + "Content-Disposition: form-data; name=\"file2\"; filename=\"a.html\"\r\n" + "Content-Type: text/html\r\n" + "\r\n" + "Content of a.html.\r\n" + "\r\n" + "--9051914041544843365972754266--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(threePartBody)) let rd = new MultipartReader("9051914041544843365972754266", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) // 10mb max file size form.Files.Count |> should equal 2 // can we access the files? use ms = new MemoryStream() use st1 = form.Files.[0].OpenReadStream() st1.CopyTo(ms) ms.SetLength(0) use st2 = form.Files.[1].OpenReadStream() st1.CopyTo(ms) let formData = FormData(RequestValue.parseForm(form, None), Some form.Files) let requestValue = formData?name.AsString() requestValue |> should equal "falco" } [] let ``MultipartReader.StreamSectionsAsync() should reject file exceeding max size`` () = let largeFileBody = "--boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"large.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + (String.replicate (11 * 1024 * 1024) "x") + "\r\n" + // Actually 11MB "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(largeFileBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let maxSize = 10L * 1024L * 1024L // 10MB max // Should throw InvalidOperationException let! ex = Assert.ThrowsAsync( fun () -> rd.StreamSectionsAsync(tokenSource.Token, maxSize) :> Task) ex.Message.Contains("exceeds maximum size") |> should equal true } [] let ``MultipartReader.StreamSectionsAsync() should handle empty form`` () = let emptyBody = "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(emptyBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) form.Count |> should equal 0 form.Files.Count |> should equal 0 } [] let ``MultipartReader.StreamSectionsAsync() should handle multiple form fields`` () = let multiFieldBody = "--boundary\r\n" + "Content-Disposition: form-data; name=\"field1\"\r\n" + "\r\n" + "value1\r\n" + "--boundary\r\n" + "Content-Disposition: form-data; name=\"field2\"\r\n" + "\r\n" + "value2\r\n" + "--boundary\r\n" + "Content-Disposition: form-data; name=\"field3\"\r\n" + "\r\n" + "value3\r\n" + "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(multiFieldBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) form.Count |> should equal 3 form["field1"] |> should equal (StringValues("value1")) form["field2"] |> should equal (StringValues("value2")) form["field3"] |> should equal (StringValues("value3")) } [] let ``MultipartReader.StreamSectionsAsync() should handle duplicate field names`` () = let duplicateBody = "--boundary\r\n" + "Content-Disposition: form-data; name=\"tags\"\r\n" + "\r\n" + "tag1\r\n" + "--boundary\r\n" + "Content-Disposition: form-data; name=\"tags\"\r\n" + "\r\n" + "tag2\r\n" + "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(duplicateBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) form["tags"].Count |> should equal 2 form["tags"].[0] |> should equal "tag1" form["tags"].[1] |> should equal "tag2" } [] let ``MultipartReader.StreamSectionsAsync() should handle mixed files and fields`` () = let mixedBody = "--boundary\r\n" + "Content-Disposition: form-data; name=\"username\"\r\n" + "\r\n" + "john_doe\r\n" + "--boundary\r\n" + "Content-Disposition: form-data; name=\"avatar\"; filename=\"avatar.png\"\r\n" + "Content-Type: image/png\r\n" + "\r\n" + "PNG_DATA_HERE\r\n" + "--boundary\r\n" + "Content-Disposition: form-data; name=\"bio\"\r\n" + "\r\n" + "A short bio\r\n" + "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(mixedBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) form.Count |> should equal 2 // username, bio form.Files.Count |> should equal 1 form["username"] |> should equal (StringValues("john_doe")) form["bio"] |> should equal (StringValues("A short bio")) form.Files[0].FileName |> should equal "avatar.png" } [] let ``MultipartReader.StreamSectionsAsync() should preserve file content type`` () = let fileBody = "--boundary\r\n" + "Content-Disposition: form-data; name=\"document\"; filename=\"doc.json\"\r\n" + "Content-Type: application/json\r\n" + "\r\n" + "{\"key\":\"value\"}\r\n" + "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(fileBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) form.Files.Count |> should equal 1 form.Files[0].ContentType |> should equal "application/json" } [] let ``MultipartReader.StreamSectionsAsync() should skip sections with missing name`` () = let malformedBody = "--boundary\r\n" + "Content-Disposition: form-data; filename=\"file.txt\"\r\n" + // Missing name "Content-Type: text/plain\r\n" + "\r\n" + "content\r\n" + "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(malformedBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) // Should be skipped entirely form.Count |> should equal 0 form.Files.Count |> should equal 0 } [] let ``MultipartReader.StreamSectionsAsync() should skip file sections with missing filename`` () = let malformedBody = "--boundary\r\n" + "Content-Disposition: form-data; name=\"file\"\r\n" + // Missing filename "Content-Type: text/plain\r\n" + "\r\n" + "content\r\n" + "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(malformedBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, DefaultMaxSize) // Should be skipped (no filename = not a file) form.Files.Count |> should equal 0 } [] let ``MultipartReader.StreamSectionsAsync() respects custom maxFileSize parameter`` () = let fileBody = "--boundary\r\n" + "Content-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "small content\r\n" + "--boundary--\r\n" use body = new MemoryStream(Encoding.UTF8.GetBytes(fileBody)) let rd = new MultipartReader("boundary", body) task { use tokenSource = new CancellationTokenSource() let! form = rd.StreamSectionsAsync(tokenSource.Token, 1024L) // 1KB max form.Files.Count |> should equal 1 form.Files[0].Length |> should lessThan 1024L } ================================================ FILE: test/Falco.Tests/Program.fs ================================================ module Program = let [] main _ = 0 ================================================ FILE: test/Falco.Tests/RequestDataTests.fs ================================================ module Falco.Tests.RequestData open System open System.Globalization open System.IO open System.Threading open System.Threading.Tasks open Falco open FsUnit.Xunit open Xunit open Microsoft.AspNetCore.Http [] let ``RequestData extensions can convert RString primitives`` () = let requestData = RequestData(RObject [ "lowercase-on", RString "on" "uppercase-on", RString "ON" "pascalcase-on", RString "On" "lowercase-yes", RString "yes" "uppercase-yes", RString "YES" "pascalcase-yes", RString "Yes" "lowercase-off", RString "off" "uppercase-off", RString "OFF" "pascalcase-off", RString "Off" "lowercase-no", RString "no" "uppercase-no", RString "NO" "pascalcase-no", RString "No" "leadingzero", RString "012345" ]) requestData.GetBoolean "lowercase-on" |> should equal true requestData.GetBoolean "uppercase-on" |> should equal true requestData.GetBoolean "pascalcase-on" |> should equal true requestData.GetBoolean "lowercase-yes" |> should equal true requestData.GetBoolean "uppercase-yes" |> should equal true requestData.GetBoolean "pascalcase-yes" |> should equal true requestData.GetBoolean "lowercase-off" |> should equal false requestData.GetBoolean "uppercase-off" |> should equal false requestData.GetBoolean "pascalcase-off" |> should equal false requestData.GetBoolean "lowercase-no" |> should equal false requestData.GetBoolean "uppercase-no" |> should equal false requestData.GetBoolean "pascalcase-no" |> should equal false requestData.GetInt "leadingzero" |> should equal 12345 requestData.GetFloat "leadingzero" |> should equal 12345. type City = { Name : string; YearFounded : int option } type CityResult = { Count : int; Results : City list } type Weather = { Season : string; Temperature : float; Effects : string list; Cities : CityResult } [] let ``RequestData extensions should work`` () = let expected = { Season = "summer" Temperature = 23.5 Effects = [ "overcast"; "wind gusts" ] Cities = { Count = 2 Results = [ { Name = "Toronto"; YearFounded = Some 123 }; { Name = "Tokyo"; YearFounded = None } ] } } let requestValue = RObject [ "season", RString "summer" "temperature", RNumber 23.5 "effects", RList [ RString "overcast"; RString "wind gusts"] "cities", RObject [ "count", RNumber 2 "results", RList [ RObject [ "name", RString "Toronto"; "year_founded", RNumber 123 ] RObject [ "name", RString "Tokyo" ] ] ] ] let r = RequestData(requestValue) { Season = r?season.AsString() Temperature = r.GetFloat "temperature" Effects = [ for e in r?effects.AsList() do e.AsString() ] Cities = { Count = r?cities?count.AsInt() Results = [ for c in r?cities?results.AsList() do { Name = c?name.AsString() YearFounded = c?year_founded.AsIntOption() } ] } } |> should equal expected [] let ``RequestData value lookups are case-insensitive`` () = let values = dict [ "FString", seq {"John Doe"; "Jane Doe" } ] let scr = RequestData(values) // single values scr.GetString "FSTRING" |> should equal "John Doe" scr.GetString "FString" |> should equal "John Doe" scr.GetString "fstriNG" |> should equal "John Doe" // arrays scr.GetStringList "FSTRING" |> should equal ["John Doe";"Jane Doe"] scr.GetStringList "fString" |> should equal ["John Doe";"Jane Doe"] scr.GetStringList "fstriNG" |> should equal ["John Doe";"Jane Doe"] [] let ``RequestData collection should resolve primitives`` () = let dt = DateTime(1986, 12, 12) let dtStr = dt.ToString("o") let dtOffsetStr = DateTimeOffset(dt).ToString("o") let timespanStr = TimeSpan.FromSeconds(1.0).ToString() let guidStr = Guid.NewGuid().ToString() let values = dict [ "emptystring", seq { "" } "fstring", seq { "John Doe"; "";""; "Jane Doe";"" } "fint16", seq { "16";"";"17" } "fint32", seq { "32";"";"";"";"";"33" } "fint64", seq { "64";"65";"";"" } "fbool", seq { "true";"false" } "ffloat", seq { "1.234";"1.235" } "fdecimal", seq { "4.567";"4.568" } "fdatetime", seq { dtStr } "fdatetimeoffset", seq { dtOffsetStr } "ftimespan", seq { timespanStr } "fguid", seq { guidStr } ] let scr = RequestData(values) // single values scr.GetString "_fstring" |> should equal "" scr.GetString "fstring" |> should equal "John Doe" scr.GetStringNonEmpty "fstring" |> should equal "John Doe" scr.GetInt16 "fint16" |> should equal 16s scr.GetInt32 "fint32" |> should equal 32 scr.GetInt "fint32" |> should equal 32 scr.GetInt64 "fint64" |> should equal 64L scr.GetBoolean "fbool" |> should equal true scr.GetFloat "ffloat" |> should equal 1.234 scr.GetDecimal "fdecimal" |> should equal 4.567M scr.GetDateTime "fdatetime" |> should equal (DateTime.Parse(dtStr, null, DateTimeStyles.RoundtripKind)) // TODO uncomment this when DateTimeOffset is supported properly on linux distros, see https://learn.microsoft.com/en-us/dotnet/standard/base-types/how-to-round-trip-date-and-time-values // scr.GetDateTimeOffset "fdatetimeoffset" |> should equal (DateTimeOffset.Parse(dtOffsetStr, null, DateTimeStyles.RoundtripKind)) scr.GetTimeSpan "ftimespan" |> should equal (TimeSpan.Parse(timespanStr)) scr.GetGuid "fguid" |> should equal (Guid.Parse(guidStr)) // default values scr.GetString("_fstring", "default_value") |> should equal "default_value" scr.GetStringNonEmpty("_fstring", "default_value") |> should equal "default_value" scr.GetInt16("_fint16", -1s) |> should equal -1s scr.GetInt32("_fint32", -1) |> should equal -1 scr.GetInt("_fint32", -1) |> should equal -1 scr.GetInt64("_fint64", 1L) |> should equal 1L scr.GetBoolean("_fbool", false) |> should equal false scr.GetFloat("_ffloat", 0.0) |> should equal 0.0 scr.GetDecimal("_fdecimal", 0.0M) |> should equal 0.0M scr.GetDateTime("_fdatetime", DateTime.MinValue) |> should equal DateTime.MinValue scr.GetDateTimeOffset("_fdatetimeoffset", DateTimeOffset.MinValue) |> should equal DateTimeOffset.MinValue scr.GetTimeSpan("_ftimespan", TimeSpan.MinValue) |> should equal TimeSpan.MinValue scr.GetGuid("_fguid", Guid.Empty) |> should equal Guid.Empty // array values scr.GetStringList "_fstring" |> List.isEmpty |> should equal true scr.GetStringList "fstriNg" |> should equal ["John Doe"; ""; ""; "Jane Doe"; ""] scr.GetStringNonEmptyList "fstring" |> should equal ["John Doe";"Jane Doe"] scr.GetInt16List "fint16" |> should equal [16s;17s] scr.GetInt32List "fint32" |> should equal [32;33] scr.GetIntList "fint32" |> should equal [32;33] scr.GetInt64List "fint64" |> should equal [64L;65L] scr.GetBooleanList "fbool" |> should equal [true;false] scr.GetFloatList "ffloat" |> should equal [1.234;1.235] scr.GetDecimalList "fdecimal" |> should equal [4.567M;4.568M] scr.GetDateTimeList "fdatetime" |> should equal [DateTime.Parse(dtStr, null, DateTimeStyles.RoundtripKind)] // TODO uncomment this when DateTimeOffset is supported properly on linux distros, see https://learn.microsoft.com/en-us/dotnet/standard/base-types/how-to-round-trip-date-and-time-values // scr.GetDateTimeOffsetList "fdatetimeoffset" |> should equal [DateTimeOffset.Parse(dtOffsetStr, null, DateTimeStyles.RoundtripKind)] scr.GetTimeSpanList "ftimespan" |> should equal [TimeSpan.Parse(timespanStr)] scr.GetGuidList "fguid" |> should equal [Guid.Parse(guidStr)] [] let ``RequestData Empty should return defaults and empty collections`` () = let r = RequestData.Empty r.AsString() |> should equal "" r.AsStringOption() |> should equal (Some "") r.AsIntOption() |> should equal None r.AsStringList() |> Seq.length |> should equal 0 r.AsList() |> Seq.length |> should equal 0 r.AsKeyValues() |> Seq.length |> should equal 0 [] let ``RequestData should allow list access on non-list primitives`` () = let r1 = RequestData(RString "hello") let r2 = RequestData(RString "123") r1.AsStringList() |> should equal ["hello"] r2.AsInt32List() |> should equal [123] [] let ``RequestData nested lookup should be case-insensitive`` () = let r = RequestData( RObject [ "Foo", RObject [ "Bar", RString "baz" ] ]) r?foo?bar.AsString() |> should equal "baz" r?FOO?BAR.AsString() |> should equal "baz" [] let ``RequestData missing nested key should yield None for options`` () = let r = RequestData(RObject [ "foo", RObject [] ]) r?foo?missing.AsIntOption() |> should equal None r?foo?missing.AsGuidOption() |> should equal None [] let ``RequestData should parse boolean from numeric`` () = RequestData(RNumber 0.).AsBoolean() |> should equal false RequestData(RNumber 1.).AsBoolean() |> should equal true [] let ``RequestData should parse DateTime from epoch milliseconds`` () = let r = RequestData(RNumber 0.) r.AsDateTime() |> should equal (DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)) [] let ``RequestData should return None for invalid Guid and TimeSpan`` () = let r1 = RequestData(RString "not-a-guid") let r2 = RequestData(RString "not-a-timespan") r1.AsGuidOption() |> should equal None r2.AsTimeSpanOption() |> should equal None [] let ``Can make FormData from IFormCollection`` () = FormData(RequestValue.RNull, Some (FormFileCollection() :> IFormFileCollection)) |> should not' throw [] let ``Can safely get IFormFile from IFormCollection`` () = let formFileName = "abc.txt" let emptyFormData = FormData(RequestValue.RNull, Some (FormFileCollection() :> IFormFileCollection)) emptyFormData.TryGetFile formFileName |> shouldBeNone let formFile = { new IFormFile with member _.ContentDisposition = String.Empty member _.ContentType = String.Empty member _.FileName = String.Empty member _.Headers = HeaderDictionary() member _.Length = Int64.MinValue member _.Name = formFileName member _.CopyTo (target: Stream) : unit = () member _.CopyToAsync (target: Stream, cancellationToken: CancellationToken) : Task = Task.CompletedTask member _.OpenReadStream () : Stream = System.IO.Stream.Null } let formFileCollection = FormFileCollection() formFileCollection.Add(formFile) let formFileData = new FormData(RequestValue.RNull, Some(formFileCollection)) formFileData.TryGetFile formFileName |> shouldBeSome (fun _ -> ()) [] let ``FormData TryGetFile should return None for null or whitespace name`` () = let fd = FormData(RequestValue.RNull, Some (FormFileCollection() :> IFormFileCollection)) fd.TryGetFile null |> shouldBeNone fd.TryGetFile "" |> shouldBeNone fd.TryGetFile " " |> shouldBeNone // ...existing code... [] let ``RequestData RNull conversions should return defaults`` () = let r = RequestData(RNull) r.AsString() |> should equal "" r.AsInt32() |> should equal 0 r.AsBoolean() |> should equal false r.AsFloat() |> should equal 0. r.AsDecimal() |> should equal 0M r.AsGuid() |> should equal Guid.Empty r.AsDateTime() |> should equal DateTime.MinValue r.AsTimeSpan() |> should equal TimeSpan.MinValue [] let ``RequestData RBool conversions to string`` () = let rTrue = RequestData(RBool true) let rFalse = RequestData(RBool false) rTrue.AsString() |> should equal "true" rFalse.AsString() |> should equal "false" [] let ``RequestData RNumber conversions to string`` () = let r = RequestData(RNumber 42.5) r.AsString() |> should equal "42.5" [] let ``RequestData TryGet methods return None on invalid conversions`` () = let r = RequestData(RString "not-a-number") r.TryGetInt32("value") |> should equal None r.TryGetBoolean("value") |> should equal None r.TryGetGuid("value") |> should equal None [] let ``RequestData empty string should convert to RNull equivalent`` () = let r = RequestData(RString "") r.AsString() |> should equal "" r.AsStringNonEmpty() |> should equal "" [] let ``RequestData whitespace string should convert to empty for AsString`` () = let r = RequestData(RString " ") r.AsString() |> should equal " " // preserves whitespace r.AsStringNonEmpty() |> should equal " " // preserves whitespace [] let ``RequestData RObject accessed as primitive should return defaults`` () = let r = RequestData(RObject [ "key", RString "value" ]) r.AsString() |> should equal "" r.AsInt32() |> should equal 0 r.AsBoolean() |> should equal false [] let ``RequestData negative numbers in lists`` () = let r = RequestData(RList [ RNumber -5.; RNumber -10.; RNumber -15. ]) r.AsInt32List() |> should equal [-5; -10; -15] [] let ``RequestData very large numbers should overflow gracefully`` () = let r = RequestData(RNumber (float System.Int32.MaxValue + 1000.)) r.AsInt32Option() |> should equal None // out of range r.AsInt64Option() |> should not' (equal None) // should fit in Int64 [] let ``RequestData float precision edge cases`` () = let r1 = RequestData(RNumber Double.NaN) let r2 = RequestData(RNumber Double.PositiveInfinity) let r3 = RequestData(RNumber Double.NegativeInfinity) r1.AsFloat() |> Double.IsNaN |> should equal true r2.AsFloat() |> should equal Double.PositiveInfinity r3.AsFloat() |> should equal Double.NegativeInfinity [] let ``RequestData empty list`` () = let r = RequestData(RList []) r.AsStringList() |> Seq.length |> should equal 0 r.AsInt32List() |> Seq.length |> should equal 0 r.AsList() |> Seq.length |> should equal 0 [] let ``RequestData list with mixed null and valid values`` () = let r = RequestData(RList [ RString "hello"; RNull; RString "world" ]) r.AsStringList() |> should equal ["hello"; ""; "world"] [] let ``RequestData single-item list`` () = let r = RequestData(RList [ RNumber 42. ]) r.AsInt32List() |> should equal [42] [] let ``RequestData very large list`` () = let large = List.init 1000 (fun i -> RNumber (float i)) let r = RequestData(RList large) r.AsInt32List() |> List.length |> should equal 1000 r.AsInt32List() |> List.head |> should equal 0 r.AsInt32List() |> List.last |> should equal 999 [] let ``RequestData deeply nested objects`` () = let r = RequestData( RObject [ "level1", RObject [ "level2", RObject [ "level3", RString "deep" ] ] ]) r?level1?level2?level3.AsString() |> should equal "deep" [] let ``RequestData RNumber 0 and 1 as boolean`` () = let r0 = RequestData(RNumber 0.) let r1 = RequestData(RNumber 1.) r0.AsBoolean() |> should equal false r1.AsBoolean() |> should equal true [] let ``RequestData RNumber other than 0/1 as boolean should return None`` () = let r = RequestData(RNumber 2.) r.AsBooleanOption() |> should equal None [] let ``RequestData invalid Guid string`` () = let r = RequestData(RString "not-a-valid-guid") r.AsGuidOption() |> should equal None r.AsGuid() |> should equal Guid.Empty [] let ``RequestData invalid TimeSpan string`` () = let r = RequestData(RString "not-a-timespan") r.AsTimeSpanOption() |> should equal None r.AsTimeSpan() |> should equal TimeSpan.MinValue [] let ``RequestData Get vs TryGet difference`` () = let r = RequestData(RObject [ "name", RString "John" ]) let getResult = r.Get "missing" let tryResult = r.TryGet "missing" getResult.AsString() |> should equal "" // Get returns Empty tryResult |> should equal None // TryGet returns None [] let ``RequestData custom default values`` () = let r = RequestData(RString "invalid") r.AsInt32(defaultValue = 999) |> should equal 999 r.AsString(defaultValue = "default") |> should equal "invalid" r.AsBoolean(defaultValue = true) |> should equal true ================================================ FILE: test/Falco.Tests/RequestTests.fs ================================================ module Falco.Tests.Request open System open System.Collections.Generic open System.IO open System.Text open System.Text.Json open System.Text.Json.Serialization open System.Threading open Falco open FsUnit.Xunit open NSubstitute open Xunit open Microsoft.AspNetCore.Authentication open Microsoft.AspNetCore.Routing open Microsoft.Net.Http.Headers open Microsoft.AspNetCore.Http open Microsoft.Extensions.Primitives [] let ``Request.getVerb should return HttpVerb from HttpContext`` () = let ctx = getHttpContextWriteable false ctx.Request.Method <- "GET" Request.getVerb ctx |> should equal GET [] let ``Request.getVerb should handle all HTTP methods`` () = let ctx = getHttpContextWriteable false ctx.Request.Method <- "POST" Request.getVerb ctx |> should equal POST ctx.Request.Method <- "PUT" Request.getVerb ctx |> should equal PUT ctx.Request.Method <- "PATCH" Request.getVerb ctx |> should equal PATCH ctx.Request.Method <- "DELETE" Request.getVerb ctx |> should equal DELETE ctx.Request.Method <- "OPTIONS" Request.getVerb ctx |> should equal OPTIONS [] let ``Request.getVerb should return ANY for unknown methods`` () = let ctx = getHttpContextWriteable false ctx.Request.Method <- "CUSTOM" Request.getVerb ctx |> should equal ANY [] let ``Request.bodyString handler should provide body string`` () = let ctx = getHttpContextWriteable false let bodyContent = "test content" use ms = new MemoryStream(Encoding.UTF8.GetBytes(bodyContent)) ctx.Request.Body <- ms let handle body : HttpHandler = body |> should equal bodyContent Response.ofEmpty Request.bodyString handle ctx [] let ``Request.getBodyString should read request body as string`` () = let ctx = getHttpContextWriteable false let bodyContent = "Hello, World!" use ms = new MemoryStream(Encoding.UTF8.GetBytes(bodyContent)) ctx.Request.Body <- ms task { let! body = Request.getBodyString ctx body |> should equal bodyContent } [] let ``Request.getBodyStringOptions should enforce max size limit`` () = let ctx = getHttpContextWriteable false let largeContent = String.replicate (11 * 1024 * 1024) "x" use ms = new MemoryStream(Encoding.UTF8.GetBytes(largeContent)) ctx.Request.Body <- ms task { let maxSize = 10L * 1024L * 1024L let! ex = Assert.ThrowsAsync( fun () -> Request.getBodyStringOptions maxSize ctx) ex.Message.Contains "exceeds maximum size" |> should equal true } [] let ``Request.getBodyString should handle empty body`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream() ctx.Request.Body <- ms task { let! body = Request.getBodyString ctx body |> should equal "" } [] let ``Request.getCookies`` () = let ctx = getHttpContextWriteable false ctx.Request.Cookies <- Map.ofList ["name", "falco"] |> cookieCollection let cookies= Request.getCookies ctx cookies?name.AsString() |> should equal "falco" [] let ``Request.getCookies should handle multiple values`` () = let ctx = getHttpContextWriteable false ctx.Request.Cookies <- Map.ofList ["session", "abc123"; "theme", "dark"] |> cookieCollection let cookies = Request.getCookies ctx cookies.GetString "session" |> should equal "abc123" cookies.GetString "theme" |> should equal "dark" [] let ``Request.getHeaders should work for present and missing header names`` () = let serverName = "Kestrel" let ctx = getHttpContextWriteable false ctx.Request.Headers.Add(HeaderNames.Server, StringValues(serverName)) let headers = Request.getHeaders ctx headers.GetString HeaderNames.Server |> should equal serverName headers.TryGetString "missing" |> should equal None [] let ``Request.getHeaders should be case-insensitive`` () = let ctx = getHttpContextWriteable false ctx.Request.Headers.Add("X-Custom-Header", StringValues("value123")) let headers = Request.getHeaders ctx headers.GetString "x-custom-header" |> should equal "value123" headers.GetString "X-CUSTOM-HEADER" |> should equal "value123" [] let ``Request.getRouteValues should return Map from HttpContext`` () = let ctx = getHttpContextWriteable false ctx.Request.RouteValues <- RouteValueDictionary({|name="falco"|}) let route = Request.getRoute ctx route.GetString "name" |> should equal "falco" [] let ``Request.getRoute should preserve large int64 values as strings`` () = // Regression test for https://github.com/falcoframework/Falco/issues/149 let ctx = getHttpContextWriteable false ctx.Request.RouteValues <- RouteValueDictionary() ctx.Request.RouteValues.Add("id", "9223372036854775807") // Int64.MaxValue as string let route = Request.getRoute ctx // Should return the original string, not scientific notation route.GetString "id" |> should equal "9223372036854775807" // Should also be parseable as int64 route.GetInt64 "id" |> should equal 9223372036854775807L [] let ``Request.getQuery should exclude route values`` () = let ctx = getHttpContextWriteable false ctx.Request.RouteValues <- RouteValueDictionary({|id="123"|}) let query = Dictionary() query.Add("filter", StringValues("active")) ctx.Request.Query <- QueryCollection(query) let queryData = Request.getQuery ctx queryData.GetString "filter" |> should equal "active" queryData.TryGetString "id" |> should equal None [] let ``Request.getForm should handle urlencoded form data`` () = let ctx = getHttpContextWriteable false ctx.Request.ContentType <- "application/x-www-form-urlencoded" let form = Dictionary() form.Add("username", StringValues("john")) form.Add("password", StringValues("secret")) let f = FormCollection(form) ctx.Request.ReadFormAsync().Returns(f) |> ignore ctx.Request.ReadFormAsync(Arg.Any()).Returns(f) |> ignore task { let! formData = Request.getForm ctx formData.GetString "password" |> should equal "secret" formData.GetString "username" |> should equal "john" } [] let ``Request.getForm should detect multipart form data and stream`` () = let ctx = getHttpContextWriteable false let body = "--9051914041544843365972754266\r\n" + "Content-Disposition: form-data; name=\"name\"\r\n" + "\r\n" + "falco\r\n" + "--9051914041544843365972754266\r\n" + "Content-Disposition: form-data; name=\"file1\"; filename=\"a.txt\"\r\n" + "Content-Type: text/plain\r\n" + "\r\n" + "Content of a.txt.\r\n" + "\r\n" + "--9051914041544843365972754266\r\n" + "Content-Disposition: form-data; name=\"file2\"; filename=\"a.html\"\r\n" + "Content-Type: text/html\r\n" + "\r\n" + "Content of a.html.\r\n" + "\r\n" + "--9051914041544843365972754266--\r\n"; use ms = new MemoryStream(Encoding.UTF8.GetBytes(body)) ctx.Request.Body.Returns(ms) |> ignore let contentType = "multipart/form-data;boundary=\"9051914041544843365972754266\"" ctx.Request.ContentType <- contentType task { let! formData = Request.getForm ctx formData.GetString "name" |> should equal "falco" formData.Files |> Option.map Seq.length |> Option.defaultValue 0 |> should equal 2 // read file 1 use file1Stream = formData.Files.Value.[0].OpenReadStream() use reader1 = new StreamReader(file1Stream) let! file1Content = reader1.ReadToEndAsync() file1Content |> should equal "Content of a.txt.\r\n" // read file 2 use file2Stream = formData.Files.Value.[1].OpenReadStream() use reader2 = new StreamReader(file2Stream) let! file2Content = reader2.ReadToEndAsync() file2Content |> should equal "Content of a.html.\r\n" } [] let ``Request.getJson should deserialize with case insensitive property names`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream(Encoding.UTF8.GetBytes "{\"NAME\":\"falco\"}") ctx.Request.ContentType <- "application/json" ctx.Request.Body <- ms task { let! json = Request.getJson ctx json.Name |> should equal "falco" } [] let ``Request.getJson should allow trailing commas`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream(Encoding.UTF8.GetBytes "{\"name\":\"falco\",}") ctx.Request.ContentType <- "application/json" ctx.Request.Body <- ms task { let! json = Request.getJson ctx json.Name |> should equal "falco" } [] let ``Request.mapJson`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream(Encoding.UTF8.GetBytes "{\"name\":\"falco\"}") ctx.Request.ContentType <- "application/json" ctx.Request.ContentLength.Returns(13L) |> ignore ctx.Request.Body.Returns(ms) |> ignore let handle json : HttpHandler = json.Name |> should equal "falco" Response.ofEmpty task { do! Request.mapJson handle ctx } [] let ``Request.mapJson should handle empty body`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream() ctx.Request.ContentType <- "application/json" ctx.Request.Body <- ms let handle json : HttpHandler = json.Name |> should equal null Response.ofEmpty task { do! Request.mapJson handle ctx } [] let ``Request.mapJsonOption`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream(Encoding.UTF8.GetBytes "{\"name\":\"falco\",\"age\":null}") ctx.Request.ContentType <- "application/json" ctx.Request.ContentLength.Returns 22L |> ignore ctx.Request.Body.Returns(ms) |> ignore let handle json : HttpHandler = json.Name |> should equal "falco" Response.ofEmpty let options = JsonSerializerOptions() options.AllowTrailingCommas <- true options.PropertyNameCaseInsensitive <- true options.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull task { do! Request.mapJsonOptions options handle ctx } [] let ``Request.mapJsonOptions with null value should deserialize`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream(Encoding.UTF8.GetBytes "{\"name\":null}") ctx.Request.Body <- ms let handle json : HttpHandler = json.Name |> should equal null Response.ofEmpty let options = JsonSerializerOptions(PropertyNameCaseInsensitive = true) task { do! Request.mapJsonOptions options handle ctx } [] let ``Request.mapJson Transfer-Encoding: chunked`` () = let ctx = getHttpContextWriteable false use ms = new MemoryStream(Encoding.UTF8.GetBytes "{\"name\":\"falco\"}") ctx.Request.ContentType <- "application/json" // Simulate chunked transfer encoding ctx.Request.Headers.Add(HeaderNames.TransferEncoding, "chunked") ctx.Request.Body.Returns(ms) |> ignore let handle json : HttpHandler = json.Name |> should equal "falco" Response.ofEmpty task { do! Request.mapJson handle ctx } [] let ``Request.mapCookies`` () = let ctx = getHttpContextWriteable false ctx.Request.Cookies <- Map.ofList ["name", "falco"] |> cookieCollection let handle name : HttpHandler = name |> should equal "falco" Response.ofEmpty task { do! Request.mapCookies (fun r -> r.GetString "name") handle ctx } [] let ``Request.mapHeaders`` () = let serverName = "Kestrel" let ctx = getHttpContextWriteable false ctx.Request.Headers.Add(HeaderNames.Server, StringValues(serverName)) let handle server : HttpHandler = server |> should equal serverName Response.ofEmpty task { do! Request.mapHeaders (fun r -> r.GetString HeaderNames.Server) handle ctx } [] let ``Request.mapRoute`` () = let ctx = getHttpContextWriteable false ctx.Request.RouteValues <- RouteValueDictionary {|name="falco"|} let handle name : HttpHandler = name |> should equal "falco" Response.ofEmpty task { do! Request.mapRoute (fun r -> r.GetString "name") handle ctx } [] let ``Request.mapQuery`` () = let ctx = getHttpContextWriteable false let query = Dictionary() query.Add("name", StringValues "falco") ctx.Request.Query <- QueryCollection query let handle name : HttpHandler = name |> should equal "falco" Response.ofEmpty task { do! Request.mapQuery (fun c -> c.GetString "name") handle ctx } [] let ``Request.mapForm`` () = let ctx = getHttpContextWriteable false let form = Dictionary() form.Add("name", StringValues "falco") let f = FormCollection(form) ctx.Request.ReadFormAsync().Returns(f) |> ignore ctx.Request.ReadFormAsync(Arg.Any()).Returns(f) |> ignore let handle name : HttpHandler = name |> should equal "falco" Response.ofEmpty task { do! Request.mapForm (fun f -> f?name.AsString()) handle ctx } [] let ``Request.authenticate should call AuthenticateAsync`` () = let ctx = getHttpContextWriteable true let handle (result: AuthenticateResult) : HttpHandler = result.Succeeded |> should equal true Response.ofEmpty task { do! Request.authenticate AuthScheme handle ctx } [] let ``Request.ifAuthenticated should allow authenticated users`` () = let ctx = getHttpContextWriteable true let mutable visited = false let handle : HttpHandler = fun ctx -> visited <- true Response.ofEmpty ctx task { do! Request.ifAuthenticated AuthScheme handle ctx visited |> should equal true } [] let ``Request.ifNotAuthenticated should block authenticated users`` () = let ctx = getHttpContextWriteable false let mutable visited = false let handle : HttpHandler = fun ctx -> visited <- true Response.ofEmpty ctx task { do! Request.ifNotAuthenticated AuthScheme handle ctx visited |> should equal true } [] let ``Request.ifAuthenticatedInRole should allow users in correct role`` () = let ctx = getHttpContextWriteable true let mutable visited = false let handle : HttpHandler = fun ctx -> visited <- true Response.ofEmpty ctx task { do! Request.ifAuthenticatedInRole AuthScheme (List.take 1 Common.AuthRoles) handle ctx visited |> should equal true } [] let ``Request.ifAuthenticatedInRole should block users not in role`` () = let ctx = getHttpContextWriteable true let mutable visited = false let handle : HttpHandler = fun ctx -> visited <- true Response.ofEmpty ctx task { do! Request.ifAuthenticatedInRole AuthScheme ["admin2"] handle ctx visited |> should equal false } ================================================ FILE: test/Falco.Tests/RequestValueTests.fs ================================================ module Falco.Tests.RequestValue open Falco open FsUnit.Xunit open Xunit [] let ``RequestValue should parse empty string as RNull`` () = let expected = RObject [ "value", RNull ] RequestValue.parseString "value=" |> should equal expected [] let ``RequestValue should parse whitespace-only string as RNull`` () = let expected = RObject [ "value", RNull ] RequestValue.parseString "value= " |> should equal expected [] [] [] [] let ``RequestValue should parse negative numbers as RNumber`` (input, expected) = let expectedValue = RObject [ "value", RNumber expected ] RequestValue.parseString $"value={input}" |> should equal expectedValue [] [] [] [] let ``RequestValue should parse small decimal values as RNumber`` (input, expected) = let expectedValue = RObject [ "value", RNumber expected ] RequestValue.parseString $"value={input}" |> should equal expectedValue [] [] [] [] [] let ``RequestValue should parse scientific notation as RString`` (input, expected) = let expectedValue = RObject [ "value", RNumber input ] RequestValue.parseString $"value={input}" |> should equal expectedValue [] let ``RequestValue should return empty RObject for incomplete request body`` () = let expected = RObject [] "" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse simple pair`` () = let expected = RObject [ "name", RString "john doe" ] "name=john%20doe" |> RequestValue.parseString |> should equal expected [] [] [] [] [] [] [] let ``RequestValue should parse RBool`` (input, expected) = let expected = RObject [ "code", RBool expected ] $"code={input}" |> RequestValue.parseString |> should equal expected [] [] [] [] [] [] [] [] let ``RequestValue should parse RString`` (input) = let expected = RObject [ "code", RString input ] $"code={input}" |> RequestValue.parseString |> should equal expected [] [] [] [] [] let ``RequestValue should parse RNumber`` (input, expected) = let expected = RObject [ "code", RNumber expected ] $"code={input}" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse int with leading zero as string`` () = let expected = RObject [ "code", RString "0123456" ] "code=0123456" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse large int64 values as string to preserve precision`` () = // Int64.MaxValue = 9223372036854775807 has 19 digits, exceeds float64 precision let expected = RObject [ "id", RString "9223372036854775807" ] "id=9223372036854775807" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse 15 digit integers as RNumber`` () = // 15 digits is within float64 precision let expected = RObject [ "id", RNumber 123456789012345. ] "id=123456789012345" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse 16+ digit integers as RString`` () = // 16+ digits exceeds float64 precision let expected = RObject [ "id", RString "1234567890123456" ] "id=1234567890123456" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse multiple simple pairs`` () = let expected = RObject [ "season", RString "summer" "orders", RNumber 2 ] "season=summer&orders=2" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse explicit list`` () = let expected = RObject [ "season", RList [ RString "summer"; RString "winter" ] ] "season[]=summer&season[]=winter" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse indexed list`` () = let expected = RObject [ "season", RList [ RString "summer"; RString "winter" ] ] "season[0]=summer&season[1]=winter" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse out-of-order indexed list`` () = let expected = RObject [ "season", RList [ RString "summer"; RString "winter" ] ] "season[1]=winter&season[0]=summer" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse jagged indexed list`` () = let expected = RObject [ "season", RList [ RString "summer"; RNull; RString "winter" ] ] "season[0]=summer&season[2]=winter" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse out-of-order, jagged indexed list`` () = let expected = RObject [ "season", RList [ RString "summer"; RNull; RString "winter" ] ] "season[2]=winter&season[0]=summer" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse object with indexed list`` () = let expected = RObject [ "user", RObject [ "name", RString "john doe" "hobbies", RList [ RString "cycling"; RString "hiking" ] ] ] "user.name=john%20doe&user.hobbies[0]=cycling&user.hobbies[1]=hiking" |> RequestValue.parseString |> should equal expected [] let ``RequestValue should parse complex`` () = let expected = RObject [ "season", RString "summer" "orders", RNumber 2 "tags", RList [ RString "clothing"; RString "shoes"] "user", RObject [ "name", RString "john" "age", RNumber 97 "hobbies", RList [ RString "cycling"; RString "hiking" ] "cards", RList [ RObject [ "num", RNumber 123 "kind", RString "visa" ] RObject [ "num", RNumber 456 "kind", RString "visa" ] ] ] ] let requestValue = seq { "season", seq { "summer" } "orders", seq { "2" } "tags[]", seq { "clothing"; "shoes" } "user.name", seq { "john" } "user.age", seq { "97" } "user.hobbies[]", seq { "cycling"; "hiking" } "user.cards[].num", seq { "123"; "456" } "user.cards[].kind", seq { "visa"; "amex" } } |> dict |> RequestValue.parse requestValue |> should equal expected [] let ``RequestValue should parse nested objects without lists`` () = let expected = RObject [ "user", RObject [ "name", RString "john"; "age", RNumber 30.0 ] ] RequestValue.parseString "user.name=john&user.age=30" |> should equal expected [] let ``RequestValue should parse deeply nested structures (3+ levels)`` () = let expected = RObject [ "user", RObject [ "profile", RObject [ "address", RObject [ "city", RString "NYC" ] ] ] ] RequestValue.parseString "user.profile.address.city=NYC" |> should equal expected [] let ``RequestValue should parse mixed flat keys and nested keys`` () = let expected = RObject [ "id", RNumber 123.0; "user", RObject [ "name", RString "john" ] ] RequestValue.parseString "id=123&user.name=john" |> should equal expected [] let ``RequestValue should handle empty list values`` () = let expected = RObject [ "items", RList [ RNull; RString "value" ] ] RequestValue.parseString "items[]=&items[]=value" |> should equal expected [] let ``RequestValue should handle invalid indexed list syntax`` () = // Non-numeric index should be treated as literal key let expected = RObject [ "items[abc]", RString "value" ] RequestValue.parseString "items[abc]=value" |> should equal expected [] let ``RequestValue should handle sparse indexed lists`` () = // items[0]=a&items[2]=c should have RNull at index 1 let expected = RObject [ "items", RList [ RString "a"; RNull; RString "c" ] ] RequestValue.parseString "items[0]=a&items[2]=c" |> should equal expected [] let ``RequestValue should handle duplicate keys concatenation`` () = // Last value should win for flat keys let expected = RObject [ "name", RList [ RString "john"; RString "jane" ] ] RequestValue.parseString "name=john&name=jane" |> should equal expected [] let ``RequestValue should handle empty input`` () = let expected = RObject [] RequestValue.parseString "" |> should equal expected ================================================ FILE: test/Falco.Tests/ResponseTests.fs ================================================ module Falco.Tests.Response open System.Security.Claims open System.Text open System.Text.Json open System.Text.Json.Serialization open Falco open Falco.Markup open FsUnit.Xunit open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Authentication open Microsoft.AspNetCore.Http open Microsoft.Extensions.Primitives open Microsoft.Net.Http.Headers open NSubstitute open Xunit [] let ``Response.withStatusCode should modify HttpResponse StatusCode`` () = let ctx = getHttpContextWriteable false let expected = 204 task { do! ctx |> (Response.withStatusCode expected >> Response.ofEmpty) ctx.Response.StatusCode |> should equal expected } [] let ``Response.withHeaders should set header`` () = let serverName = "Kestrel" let ctx = getHttpContextWriteable false task { do! ctx |> (Response.withHeaders [ HeaderNames.Server, serverName ] >> Response.ofEmpty) ctx.Response.Headers.[HeaderNames.Server][0] |> should equal serverName } [] let ``Response.withHeaders should set multiple headers`` () = let ctx = getHttpContextWriteable false let headers = [ HeaderNames.Server, "Kestrel" HeaderNames.CacheControl, "no-cache" ] task { do! ctx |> (Response.withHeaders headers >> Response.ofEmpty) ctx.Response.Headers.[HeaderNames.Server][0] |> should equal "Kestrel" ctx.Response.Headers.[HeaderNames.CacheControl][0] |> should equal "no-cache" } [] let ``Response.withHeaders should overwrite existing header`` () = let ctx = getHttpContextWriteable false ctx.Response.Headers.[HeaderNames.Server] <- "InitialValue" let serverName = "Kestrel" task { do! ctx |> (Response.withHeaders [ HeaderNames.Server, serverName ] >> Response.ofEmpty) ctx.Response.Headers.[HeaderNames.Server][0] |> should equal serverName } [] let ``Response.withContentType should set header`` () = let contentType = "text/plain; charset=utf-8" let ctx = getHttpContextWriteable false task { do! ctx |> (Response.withContentType contentType>> Response.ofEmpty) ctx.Response.Headers.[HeaderNames.ContentType][0] |> should equal contentType } [] let ``Response.withStatusCode with multiple modifiers`` () = let ctx = getHttpContextWriteable false task { do! ctx |> (Response.withStatusCode 201 >> Response.withContentType "application/json" >> Response.ofEmpty) ctx.Response.StatusCode |> should equal 201 ctx.Response.Headers.[HeaderNames.ContentType][0] |> should equal "application/json" } [] let ``Response.withCookie should add cookie to response`` () = let ctx = getHttpContextWriteable false let key = "sessionId" let value = "abc123" task { do! ctx |> (Response.withCookie key value >> Response.ofEmpty) ctx.Response.Cookies.Received().Append(key, value) |> ignore } [] let ``Response.withCookieOptions should add cookie with options`` () = let ctx = getHttpContextWriteable false let key = "sessionId" let value = "abc123" let options = CookieOptions() options.HttpOnly <- true options.Secure <- true task { do! ctx |> (Response.withCookieOptions options key value >> Response.ofEmpty) ctx.Response.Cookies.Received().Append(key, value, options) |> ignore } [] let ``Response chaining multiple modifiers`` () = let ctx = getHttpContextWriteable false task { do! ctx |> (Response.withStatusCode 200 >> Response.withContentType "application/json" >> Response.withHeaders [ "X-Custom", "value" ] >> Response.ofEmpty) ctx.Response.StatusCode |> should equal 200 ctx.Response.Headers.[HeaderNames.ContentType][0] |> should equal "application/json" ctx.Response.Headers.["X-Custom"][0] |> should equal "value" } [] let ``Response modifiers are composable`` () = let ctx = getHttpContextWriteable false let modifier = Response.withStatusCode 201 >> Response.withContentType "text/custom" task { do! ctx |> (modifier >> Response.ofEmpty) ctx.Response.StatusCode |> should equal 201 ctx.Response.ContentType |> should equal "text/custom" } [] let ``Response.redirectPermanentlyTo invokes HttpRedirect with permanently moved resource`` () = let ctx = getHttpContextWriteable false let permanentRedirect = true task { do! ctx |> Response.redirectPermanently "/" ctx.Response.Received().Redirect("/", permanentRedirect) ctx.Response.StatusCode |> should equal 301 } [] let ``Response.redirectTemporarilyTo invokes HttpRedirect with temporarily moved resource`` () = let ctx = getHttpContextWriteable false let permanentRedirect = false task { do! ctx |> Response.redirectTemporarily "/" ctx.Response.Received().Redirect("/", permanentRedirect) ctx.Response.StatusCode |> should equal 302 } [] let ``Response.ofEmpty produces empty response`` () = let ctx = getHttpContextWriteable false task { do! ctx |> Response.ofEmpty ctx.Response.ContentLength |> should equal 0L } [] let ``Response.ofString with whitespace-only string`` () = let ctx = getHttpContextWriteable false let whitespace = " \t\n " task { do! ctx |> Response.ofString Encoding.UTF8 whitespace let! body = getResponseBody ctx body |> should equal "" // IsNullOrWhiteSpace check } [] let ``Response.ofPlainText produces text/plain result`` () = let ctx = getHttpContextWriteable false let expected = "hello" task { do! ctx |> Response.ofPlainText expected let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] body |> should equal expected contentLength |> should equal (int64 (Encoding.UTF8.GetByteCount(expected))) contentType |> should equal "text/plain; charset=utf-8" } [] let ``Response.ofPlainText with empty string`` () = let ctx = getHttpContextWriteable false task { do! ctx |> Response.ofPlainText "" let! body = getResponseBody ctx body |> should equal "" } [] let ``Response.ofPlainText with null string`` () = let ctx = getHttpContextWriteable false task { do! ctx |> Response.ofPlainText null let! body = getResponseBody ctx body |> should equal "" } [] let ``Response.ofPlainText with multiline content`` () = let ctx = getHttpContextWriteable false let expected = "line1\nline2\nline3" task { do! ctx |> Response.ofPlainText expected let! body = getResponseBody ctx body |> should equal expected } [] let ``Response.ofPlainText with special characters`` () = let ctx = getHttpContextWriteable false let expected = "hello\r\nworld\t!\x00end" task { do! ctx |> Response.ofPlainText expected let! body = getResponseBody ctx body |> should equal expected } [] let ``Response.ofBinary produces valid inline result from Byte[]`` () = let ctx = getHttpContextWriteable false let expected = "falco" let contentType = "text/plain; charset=utf-8" task { do! ctx |> Response.ofBinary contentType [] (expected |> Encoding.UTF8.GetBytes) let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] let contentDisposition = ctx.Response.Headers.[HeaderNames.ContentDisposition][0] body |> should equal expected contentType |> should equal contentType contentDisposition |> should equal "inline" } [] let ``Response.ofBinary with special characters in content type`` () = let ctx = getHttpContextWriteable false let contentType = "application/octet-stream; charset=utf-8" let bytes = Array.zeroCreate 100 task { do! ctx |> Response.ofBinary contentType [] bytes ctx.Response.ContentType |> should equal contentType } [] let ``Response.ofBinary should preserve valid UTF-8 content`` () = let ctx = getHttpContextWriteable false let expected = "hello" let bytes = Encoding.UTF8.GetBytes(expected) task { do! ctx |> Response.ofBinary "text/plain" [] bytes let! body = getResponseBody ctx body |> should equal expected } [] let ``Response.ofAttachment produces valid attachment result from Byte[]`` () = let ctx = getHttpContextWriteable false let expected = "falco" let contentType = "text/plain; charset=utf-8" task { do! ctx |> Response.ofAttachment "falco.txt" contentType [] (expected |> Encoding.UTF8.GetBytes) let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] let contentDisposition = ctx.Response.Headers.[HeaderNames.ContentDisposition][0] body |> should equal expected contentType |> should equal contentType contentDisposition |> should equal "attachment; filename=\"falco.txt\"" } [] let ``Response.ofAttachment with special characters in filename`` () = let ctx = getHttpContextWriteable false let filename = "file with spaces & quotes.txt" let contentType = "text/plain; charset=utf-8" task { do! ctx |> Response.ofAttachment filename contentType [] (Encoding.UTF8.GetBytes("content")) let contentDisposition = ctx.Response.Headers.[HeaderNames.ContentDisposition][0] // Should have escaped quotes if necessary contentDisposition.Contains("attachment") |> should equal true } [] let ``Response.ofAttachment with empty filename`` () = let ctx = getHttpContextWriteable false let contentType = "text/plain; charset=utf-8" task { do! ctx |> Response.ofAttachment "" contentType [] (Encoding.UTF8.GetBytes("content")) let contentDisposition = ctx.Response.Headers.[HeaderNames.ContentDisposition][0] contentDisposition |> should equal "attachment" } [] let ``Response.ofAttachment preserves file extension`` () = let ctx = getHttpContextWriteable false let filename = "document.pdf" let contentType = "application/pdf" task { do! ctx |> Response.ofAttachment filename contentType [] (Encoding.UTF8.GetBytes("PDF content")) let contentDisposition = ctx.Response.Headers.[HeaderNames.ContentDisposition][0] contentDisposition.Contains("document.pdf") |> should equal true } [] let ``Response.ofAttachment with quotes in filename`` () = let ctx = getHttpContextWriteable false let filename = "file\"with\"quotes.txt" let contentType = "text/plain" task { do! ctx |> Response.ofAttachment filename contentType [] (Encoding.UTF8.GetBytes("test")) let contentDisposition = ctx.Response.Headers.[HeaderNames.ContentDisposition][0] // Should be properly escaped contentDisposition.Contains "attachment" |> should equal true } [] let ``Response.ofJson produces applicaiton/json result`` () = let ctx = getHttpContextWriteable false let expected = "{\"Name\":\"John Doe\"}" task { do! ctx |> Response.ofJson { Name = "John Doe"} let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] body |> should equal expected contentLength |> should equal (int64 (Encoding.UTF8.GetByteCount(expected))) contentType |> should equal "application/json; charset=utf-8" } [] let ``Response.ofJson with null object`` () = let ctx = getHttpContextWriteable false task { do! ctx |> Response.ofJson null let! body = getResponseBody ctx body |> should equal "null" } [] let ``Response.ofJson with empty list`` () = let ctx = getHttpContextWriteable false task { do! ctx |> Response.ofJson [] let! body = getResponseBody ctx body |> should equal "[]" } [] let ``Response.ofJson with nested objects`` () = let ctx = getHttpContextWriteable false let expected = "{\"name\":\"John\",\"nested\":{\"age\":30}}" task { do! ctx |> Response.ofJson {| name = "John"; nested = {| age = 30 |} |} let! body = getResponseBody ctx body |> should equal expected } [] let ``Response.ofJsonOptions with custom serialization settings`` () = let ctx = getHttpContextWriteable false let options = JsonSerializerOptions() options.WriteIndented <- true task { do! ctx |> Response.ofJsonOptions options {| test = "value" |} let! body = getResponseBody ctx body.Contains("test") |> should equal true } [] let ``Response.ofJsonOptions produces applicaiton/json result ignoring nulls`` () = let ctx = getHttpContextWriteable false let expected = "{}" task { let jsonOptions = JsonSerializerOptions() jsonOptions.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull do! ctx |> Response.ofJsonOptions jsonOptions { Name = null } let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] body |> should equal expected contentLength |> should equal (int64 (Encoding.UTF8.GetByteCount(expected))) contentType |> should equal "application/json; charset=utf-8" } [] let ``Response.ofJson with large object`` () = let ctx = getHttpContextWriteable false let largeList = List.init 10000 (fun i -> {| id = i; name = $"item{i}" |}) task { do! ctx |> Response.ofJson largeList let! body = getResponseBody ctx body.Contains("\"id\"") |> should equal true body.Contains("9999") |> should equal true // Check last item serialized } [] let ``Response.ofHtml produces text/html result`` () = let ctx = getHttpContextWriteable false let expected = "

hello

" let doc = _html [] [ _div [ _class_ "my-class" ] [ _h1 [] [ _text "hello" ] ] ] task { do! ctx |> Response.ofHtml doc let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] body |> should equal expected contentLength |> should equal (int64 (Encoding.UTF8.GetByteCount(expected))) contentType |> should equal "text/html; charset=utf-8" } [] let ``Response.ofHtml with empty document`` () = let ctx = getHttpContextWriteable false let doc = _html [] [] task { do! ctx |> Response.ofHtml doc let! body = getResponseBody ctx body |> should equal "" } [] let ``Response.ofHtml with attributes`` () = let ctx = getHttpContextWriteable false let doc = _html [_lang_ "en"] [_body [] []] task { do! ctx |> Response.ofHtml doc let! body = getResponseBody ctx body.Contains("lang=\"en\"") |> should equal true } [] let ``Response.ofHtmlString with empty string`` () = let ctx = getHttpContextWriteable false task { do! ctx |> Response.ofHtmlString "" let! body = getResponseBody ctx body |> should equal "" } [] let ``Response.ofHtmlString produces text/html result`` () = let ctx = getHttpContextWriteable false let expected = "

hello

" task { do! ctx |> Response.ofHtmlString expected let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] body |> should equal expected contentLength |> should equal (int64 (Encoding.UTF8.GetByteCount(expected))) contentType |> should equal "text/html; charset=utf-8" } [] let ``Response.ofFragment with non-existent fragment`` () = let ctx = getHttpContextWriteable false let html = _div [ _id_ "fragment1" ] [ _text "content" ] task { do! ctx |> Response.ofFragment "nonexistent" html let! body = getResponseBody ctx body |> should equal "" } [] let ``Response.ofFragment with multiple matching fragments`` () = let ctx = getHttpContextWriteable false let html = _div [] [ _div [ _id_ "fragment" ] [ _text "first" ] _div [ _id_ "fragment" ] [ _text "second" ] ] task { do! ctx |> Response.ofFragment "fragment" html let! body = getResponseBody ctx // Should return first match body.Contains "first" |> should equal true } [] let ``Response.ofFragment returns the specified fragment`` () = let ctx = getHttpContextWriteable false let expected = """
1
""" let html = Elem.div [] [ _div [ _id_ "fragment1" ] [ _text "1" ] _div [ _id_ "fragment2" ] [ _text "2" ] ] task { do! ctx |> Response.ofFragment "fragment1" html let! body = getResponseBody ctx let contentLength = ctx.Response.ContentLength let contentType = ctx.Response.Headers.[HeaderNames.ContentType][0] body |> should equal expected contentLength |> should equal (int64 (Encoding.UTF8.GetByteCount(expected))) contentType |> should equal "text/html; charset=utf-8" } [] let ``Response.signIn should call SignInAsync`` () = let ctx = getHttpContextWriteable true let principal = ClaimsPrincipal(ClaimsIdentity([], CookieScheme)) task { do! ctx |> Response.signIn CookieScheme principal ctx.Received().SignInAsync(CookieScheme, principal) |> ignore } [] let ``Response.signInOptions should call SignInAsync with options`` () = let ctx = getHttpContextWriteable true let principal = ClaimsPrincipal(ClaimsIdentity([], CookieScheme)) let options = AuthenticationProperties() options.IsPersistent <- true task { do! ctx |> Response.signInOptions CookieScheme principal options ctx.Received().SignInAsync(CookieScheme, principal, options) |> ignore } [] let ``Response.signInAndRedirect should set redirect URI`` () = let ctx = getHttpContextWriteable true let principal = ClaimsPrincipal(ClaimsIdentity([], CookieScheme)) let redirectUri = "/dashboard" task { do! ctx |> Response.signInAndRedirect CookieScheme principal redirectUri ctx.Received().SignInAsync(CookieScheme, principal) |> ignore ctx.Response.Headers.Location.ToArray() |> should contain redirectUri } [] let ``Response.signOut should call SignOutAsync`` () = let ctx = getHttpContextWriteable true task { do! ctx |> Response.signOut CookieScheme ctx.Received().SignOutAsync(CookieScheme) |> ignore } [] let ``Response.signOutOptions should call SignOutAsync with options`` () = let ctx = getHttpContextWriteable true let options = AuthenticationProperties() options.RedirectUri <- "/goodbye" task { do! ctx |> Response.signOutOptions CookieScheme options ctx.Received().SignOutAsync(CookieScheme, options) |> ignore } [] let ``Response.signOutAndRedirect should set redirect URI`` () = let ctx = getHttpContextWriteable true let redirectUri = "/goodbye" task { do! ctx |> Response.signOutAndRedirect CookieScheme redirectUri ctx.Received().SignOutAsync(CookieScheme) |> ignore ctx.Response.Headers.Location.ToArray() |> should contain redirectUri } [] let ``Response.challengeOptions should call ChallengeAsync with options`` () = let ctx = getHttpContextWriteable false let options = AuthenticationProperties() options.RedirectUri <- "/login" task { do! ctx |> Response.challengeOptions CookieScheme options ctx.Received().ChallengeAsync(CookieScheme, options) |> ignore } [] let ``Response.challengeAndRedirect`` () = let ctx = getHttpContextWriteable false task { do! ctx |> Response.challengeAndRedirect AuthScheme "/" ctx.Response.StatusCode |> should equal 401 ctx.Response.Headers.WWWAuthenticate.ToArray() |> should contain AuthScheme ctx.Response.Headers.Location.ToArray() |> should contain "/" } [] let ``Response.ofHtmlCsrf should include CSRF token`` () = let ctx = getHttpContextWriteable false task { let view (token : AntiforgeryTokenSet) = _html [] [ _body [] [ Security.Xsrf.antiforgeryInput token ] ] do! ctx |> Response.ofHtmlCsrf view let! body = getResponseBody ctx body.Contains("input") |> should equal true } [] let ``Response.ofFragmentCsrf should include CSRF token`` () = let ctx = getHttpContextWriteable false task { let view (token : AntiforgeryTokenSet) = _div [ _id_ "myFragment" ] [ Security.Xsrf.antiforgeryInput token ] do! ctx |> Response.ofFragmentCsrf "myFragment" view let! body = getResponseBody ctx body.Contains("input") |> should equal true } ================================================ FILE: test/Falco.Tests/RoutingTests.fs ================================================ module Falco.Tests.Routing open Xunit open Falco open Falco.Routing open FsUnit.Xunit open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Routing open System let emptyHandler : HttpHandler = Response.ofPlainText "" // ----------------- // HttpEndpoint Tests // ----------------- [] let ``route function should return valid HttpEndpoint`` () = let routeVerb = GET let routePattern = "/" let endpoint = route routeVerb routePattern emptyHandler endpoint.Pattern |> should equal routePattern let verb, handler = Seq.head endpoint.Handlers verb |> should equal routeVerb handler |> should be instanceOfType [] let ``all function should create endpoint with multiple handlers`` () = let pattern = "/api/users" let handlers = [ GET, emptyHandler POST, emptyHandler DELETE, emptyHandler ] let endpoint = all pattern handlers endpoint.Pattern |> should equal pattern endpoint.Handlers |> Seq.length |> should equal 3 let verbs = endpoint.Handlers |> Seq.map fst |> List.ofSeq verbs |> should contain GET verbs |> should contain POST verbs |> should contain DELETE [] let ``HTTP verb helpers create correct endpoints`` () = let testEndpointFunction (mapEndPoint: string -> HttpHandler -> HttpEndpoint) (expectedVerb : HttpVerb) = let pattern = "/test" let endpoint = mapEndPoint pattern emptyHandler endpoint.Pattern |> should equal pattern let (verb, handler) = Seq.head endpoint.Handlers verb |> should equal expectedVerb handler |> should be instanceOfType [ any, ANY get, GET head, HEAD post, POST put, PUT patch, PATCH delete, DELETE options, OPTIONS trace, TRACE ] |> List.iter (fun (fn, verb) -> testEndpointFunction fn verb) [] let ``mapGet should compose route mapping with handler`` () = let pattern = "/users/{id}" let mutable capturedId = "" let handler (id: string) : HttpHandler = capturedId <- id Response.ofEmpty let endpoint = mapGet pattern (fun r -> r.GetString "id") handler endpoint.Pattern |> should equal pattern let (verb, _) = Seq.head endpoint.Handlers verb |> should equal GET [] let ``mapPost should compose route mapping with handler`` () = let pattern = "/users" let endpoint = mapPost pattern (fun _ -> "test") (fun _ -> emptyHandler) let (verb, _) = Seq.head endpoint.Handlers verb |> should equal POST [] let ``all map* functions create correct verb endpoints`` () = let pattern = "/test/{id}" let map = fun (r: RequestData) -> r.GetString "id" [ mapAny, ANY mapGet, GET mapHead, HEAD mapPost, POST mapPut, PUT mapPatch, PATCH mapDelete, DELETE mapOptions, OPTIONS mapTrace, TRACE ] |> List.iter (fun (fn, verb) -> let endpoint = fn pattern map (fun _ -> emptyHandler) let (endpointVerb, _) = Seq.head endpoint.Handlers endpointVerb |> should equal verb ) [] let ``setDisplayName should configure endpoint display name`` () = let endpoint = route GET "/" emptyHandler |> setDisplayName "MyCustomEndpoint" let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.DisplayName |> should equal "MyCustomEndpoint" [] let ``setOrder should configure endpoint order`` () = let endpoint = route GET "/" emptyHandler |> setOrder 42 let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.Order |> should equal 42 [] let ``setDisplayName and setOrder can be chained`` () = let endpoint = route GET "/" emptyHandler |> setDisplayName "TestEndpoint" |> setOrder 99 let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.DisplayName |> should equal "TestEndpoint" builtEndpoint.Order |> should equal 99 // ----------------- // FalcoEndpointDataSource Tests // ----------------- [] let ``FalcoEndpointDataSource with empty constructor`` () = let dataSource = FalcoEndpointDataSource() dataSource.Endpoints |> should haveCount 0 [] let ``FalcoEndpointDataSource with single endpoint`` () = let endpoint = route GET "/" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) dataSource.Endpoints |> should haveCount 1 let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.RoutePattern.RawText |> should equal "/" [] let ``FalcoEndpointDataSource with multiple endpoints`` () = let endpoints = [ route GET "/" emptyHandler route POST "/users" emptyHandler route DELETE "/users/{id}" emptyHandler ] let dataSource = FalcoEndpointDataSource(endpoints) dataSource.Endpoints |> should haveCount 3 [] let ``FalcoEndpointDataSource with multi-verb endpoint creates separate route endpoints`` () = let endpoint = all "/api/users" [ GET, emptyHandler POST, emptyHandler PUT, emptyHandler ] let dataSource = FalcoEndpointDataSource([ endpoint ]) // Should create 3 separate RouteEndpoints (one per verb) dataSource.Endpoints |> should haveCount 3 let routeEndpoints = dataSource.Endpoints |> Seq.cast |> List.ofSeq routeEndpoints |> List.iter (fun re -> re.RoutePattern.RawText |> should equal "/api/users" ) [] let ``FalcoEndpointDataSource builds endpoints with correct HTTP method metadata`` () = let endpoint = route GET "/test" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint let httpMethodMetadata = builtEndpoint.Metadata.GetMetadata() httpMethodMetadata |> should not' (be Null) httpMethodMetadata.HttpMethods |> should contain "GET" [] let ``FalcoEndpointDataSource with ANY verb creates empty HTTP methods metadata`` () = let endpoint = route ANY "/test" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint let httpMethodMetadata = builtEndpoint.Metadata.GetMetadata() httpMethodMetadata |> should not' (be Null) httpMethodMetadata.HttpMethods |> should be Empty [] let ``FalcoEndpointDataSource builds endpoints with route name metadata`` () = let pattern = "/api/users" let endpoint = route GET pattern emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint let routeNameMetadata = builtEndpoint.Metadata.GetMetadata() routeNameMetadata |> should not' (be Null) routeNameMetadata.RouteName |> should equal pattern [] let ``FalcoEndpointDataSource default display name includes verb and pattern`` () = let endpoint = route POST "/users" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.DisplayName |> should equal "POST /users" [] let ``FalcoEndpointDataSource ANY verb display name is pattern only`` () = let endpoint = route ANY "/test" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.DisplayName |> should equal "/test" [] let ``FalcoEndpointDataSource with route parameters`` () = let endpoint = route GET "/users/{id}/posts/{postId}" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.RoutePattern.Parameters |> should haveCount 2 let paramNames = builtEndpoint.RoutePattern.Parameters |> Seq.map (fun p -> p.Name) |> List.ofSeq paramNames |> should contain "id" paramNames |> should contain "postId" [] let ``FalcoEndpointDataSource GetChangeToken returns NullChangeToken`` () = let dataSource = FalcoEndpointDataSource() let changeToken = dataSource.GetChangeToken() changeToken |> should be instanceOfType [] let ``FalcoEndpointDataSource.FalcoEndpoints can be added dynamically`` () = let dataSource = FalcoEndpointDataSource() dataSource.FalcoEndpoints.Add(route GET "/dynamic" emptyHandler) dataSource.Endpoints |> should haveCount 1 [] let ``FalcoEndpointDataSource combines constructor and FalcoEndpoints`` () = let initialEndpoint = route GET "/initial" emptyHandler let dataSource = FalcoEndpointDataSource([ initialEndpoint ]) dataSource.FalcoEndpoints.Add(route POST "/added" emptyHandler) dataSource.Endpoints |> should haveCount 2 [] let ``FalcoEndpointDataSource applies IEndpointConventionBuilder conventions`` () = let endpoint = route GET "/test" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let mutable conventionApplied = false let convention = Action(fun _ -> conventionApplied <- true) (dataSource :> IEndpointConventionBuilder).Add(convention) // Force endpoint building dataSource.Endpoints |> ignore conventionApplied |> should equal true [] let ``FalcoEndpointDataSource request delegate executes handler`` () = let mutable handlerExecuted = false let testHandler : HttpHandler = fun ctx -> handlerExecuted <- true Response.ofEmpty ctx let endpoint = route GET "/test" testHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.RequestDelegate |> should not' (be Null) [] let ``FalcoEndpointDataSource preserves endpoint pattern casing`` () = let endpoint = route GET "/API/Users" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.RoutePattern.RawText |> should equal "/API/Users" [] let ``FalcoEndpointDataSource with complex route patterns`` () = let patterns = [ "/users" "/users/{id:int}" "/users/{id}/posts/{postId:guid}" "/api/v{version:apiVersion}/users" ] let endpoints = patterns |> List.map (fun p -> route GET p emptyHandler) let dataSource = FalcoEndpointDataSource(endpoints) dataSource.Endpoints |> should haveCount 4 let builtPatterns = dataSource.Endpoints |> Seq.cast |> Seq.map (fun e -> e.RoutePattern.RawText) |> List.ofSeq patterns |> List.iter (fun p -> builtPatterns |> should contain p) [] let ``FalcoEndpointDataSource default order is 0`` () = let endpoint = route GET "/test" emptyHandler let dataSource = FalcoEndpointDataSource([ endpoint ]) let builtEndpoint = Seq.head dataSource.Endpoints :?> RouteEndpoint builtEndpoint.Order |> should equal 0 ================================================ FILE: test/Falco.Tests/SecurityTests.fs ================================================ module Falco.Tests.SecurityTests open FsUnit.Xunit open Xunit open Falco.Security open Falco.Markup open Microsoft.AspNetCore.Antiforgery module Xsrf = [] let ``antiforgetInput should return valid XmlNode`` () = let token = AntiforgeryTokenSet("REQUEST_TOKEN", "COOKIE_TOKEN", "FORM_FIELD_NAME", "HEADER_NAME") let input = Xsrf.antiforgeryInput token let expected = "" match input with | TextNode _ | ParentNode _ -> false |> should equal true | input -> renderNode input |> should equal expected ================================================ FILE: test/Falco.Tests/StringTests.fs ================================================ namespace Falco.Tests.String open System open System.Collections.Generic open System.IO open System.Text open System.Text.Json open System.Text.Json.Serialization open Falco open FsUnit.Xunit open NSubstitute open Xunit open Microsoft.AspNetCore.Routing open Microsoft.Net.Http.Headers open Microsoft.AspNetCore.Http open Microsoft.Extensions.Primitives module StringUtils = [] let ``StringUtils.strEmpty should return true for null or empty strings`` () = StringUtils.strEmpty null |> should be True StringUtils.strEmpty "" |> should be True StringUtils.strEmpty " " |> should be True [] let ``StringUtils.strNotEmpty should return true for non-empty strings`` () = StringUtils.strNotEmpty "hello" |> should be True StringUtils.strNotEmpty " " |> should be False [] let ``StringUtils.strEquals should compare strings case-insensitively`` () = StringUtils.strEquals "hello" "HELLO" |> should be True StringUtils.strEquals "hello" "world" |> should be False [] let ``StringUtils.strConcat should concatenate a sequence of strings`` () = StringUtils.strConcat ["hello"; " "; "world"] |> should equal "hello world" StringUtils.strConcat [] |> should equal "" [] let ``StringUtils.strSplit should split a string by given separators`` () = StringUtils.strSplit [|','; ' '|] "hello, world" |> should equal [|"hello"; "world"|] StringUtils.strSplit [|','|] " hello, world" |> should equal [|" hello"; " world"|] StringUtils.strSplit [|' '|] "hello world" |> should equal [|"hello"; "world"|] StringUtils.strSplit [|','|] "hello world" |> should equal [|"hello world"|] StringUtils.strSplit [|','; ' '|] " " |> should equal [||] module StringParser = [] let ``StringParser.parseBoolean should parse true/false case-insensitively`` () = StringParser.parseBoolean "true" |> should equal (Some true) StringParser.parseBoolean "True" |> should equal (Some true) StringParser.parseBoolean "TRUE" |> should equal (Some true) StringParser.parseBoolean "false" |> should equal (Some false) StringParser.parseBoolean "False" |> should equal (Some false) StringParser.parseBoolean "FALSE" |> should equal (Some false) StringParser.parseBoolean "notabool" |> should equal None StringParser.parseBoolean "" |> should equal None [] let ``StringParser.parseInt16 should parse valid int16`` () = StringParser.parseInt16 "123" |> should equal (Some 123s) StringParser.parseInt16 "-32768" |> should equal (Some -32768s) StringParser.parseInt16 "32767" |> should equal (Some 32767s) StringParser.parseInt16 "notanint" |> should equal None StringParser.parseInt16 "" |> should equal None [] let ``StringParser.parseInt32 should parse valid int32`` () = StringParser.parseInt32 "123" |> should equal (Some 123) StringParser.parseInt32 "-2147483648" |> should equal (Some -2147483648) StringParser.parseInt32 "2147483647" |> should equal (Some 2147483647) StringParser.parseInt32 "notanint" |> should equal None StringParser.parseInt32 "" |> should equal None [] let ``StringParser.parseInt64 should parse valid int64`` () = StringParser.parseInt64 "123" |> should equal (Some 123L) StringParser.parseInt64 "-9223372036854775808" |> should equal (Some -9223372036854775808L) StringParser.parseInt64 "9223372036854775807" |> should equal (Some 9223372036854775807L) StringParser.parseInt64 "notanint" |> should equal None StringParser.parseInt64 "" |> should equal None [] let ``StringParser.parseFloat should parse valid floats`` () = StringParser.parseFloat "123.45" |> should equal (Some 123.45) StringParser.parseFloat "-123.45" |> should equal (Some -123.45) StringParser.parseFloat "1e10" |> should equal (Some 1e10) StringParser.parseFloat "notafloat" |> should equal None StringParser.parseFloat "" |> should equal None [] let ``StringParser.parseDecimal should parse valid decimals`` () = StringParser.parseDecimal "123.45" |> should equal (Some 123.45M) StringParser.parseDecimal "-123.45" |> should equal (Some -123.45M) StringParser.parseDecimal "1e10" |> should equal (Some 1e10M) StringParser.parseDecimal "notadecimal" |> should equal None StringParser.parseDecimal "" |> should equal None [] let ``StringParser.parseDateTime should parse valid DateTime`` () = StringParser.parseDateTime "2021-01-01T12:00:00Z" |> should equal (Some (DateTime(2021, 1, 1, 12, 0, 0, DateTimeKind.Utc))) StringParser.parseDateTime "notadatetime" |> should equal None StringParser.parseDateTime "" |> should equal None [] let ``StringParser.parseDateTimeOffset should parse valid DateTimeOffset`` () = StringParser.parseDateTimeOffset "2021-01-01T12:00:00Z" |> should equal (Some (DateTimeOffset(2021, 1, 1, 12, 0, 0, TimeSpan.Zero))) StringParser.parseDateTimeOffset "notadatetimeoffset" |> should equal None StringParser.parseDateTimeOffset "" |> should equal None [] let ``StringParser.parseTimeSpan should parse valid TimeSpan`` () = StringParser.parseTimeSpan "12:00:00" |> should equal (Some (TimeSpan(12, 0, 0))) StringParser.parseTimeSpan "notatimespan" |> should equal None StringParser.parseTimeSpan "" |> should equal None [] let ``StringParser.parseGuid should parse valid GUIDs`` () = let guidStr = "d3b07384-d9a0-4c19-9a0c-0305e1b1c8f2" let guid = Guid.Parse(guidStr) StringParser.parseGuid guidStr |> should equal (Some guid) StringParser.parseGuid "notaguid" |> should equal None StringParser.parseGuid "" |> should equal None module StringPatterns = [] let ``StringPatterns.IsBool should match valid boolean strings`` () = match "true" with | StringPatterns.IsBool b -> b |> should equal true | _ -> failwith "Did not match" match "FALSE" with | StringPatterns.IsBool b -> b |> should equal false | _ -> failwith "Did not match" match "notabool" with | StringPatterns.IsBool _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsTrue`` () = match "true" with | StringPatterns.IsTrue _ -> () // Expected | _ -> failwith "Did not match" match "false" with | StringPatterns.IsTrue _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsFalse`` () = match "false" with | StringPatterns.IsFalse _ -> () // Expected | _ -> failwith "Did not match" match "true" with | StringPatterns.IsFalse _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsNullOrWhiteSpace should match null or whitespace strings`` () = let isNullOrWhiteSpace (str: string) = match str with | StringPatterns.IsNullOrWhiteSpace -> true | _ -> false isNullOrWhiteSpace null |> should be True isNullOrWhiteSpace "" |> should be True isNullOrWhiteSpace " " |> should be True isNullOrWhiteSpace "hello" |> should be False isNullOrWhiteSpace " hello " |> should be False [] let ``StringPatterns.IsInt16 should match valid int16 strings`` () = match "123" with | StringPatterns.IsInt16 i -> i |> should equal 123s | _ -> failwith "Did not match" match "notanint" with | StringPatterns.IsInt16 _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsInt32 should match valid int32 strings`` () = match "123456" with | StringPatterns.IsInt32 i -> i |> should equal 123456 | _ -> failwith "Did not match" match "notanint" with | StringPatterns.IsInt32 _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsInt64 should match valid int64 strings`` () = match "1234567890123" with | StringPatterns.IsInt64 i -> i |> should equal 1234567890123L | _ -> failwith "Did not match" match "notanint" with | StringPatterns.IsInt64 _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsFloat should match valid float strings`` () = match "123.45" with | StringPatterns.IsFloat f -> f |> should equal 123.45 | _ -> failwith "Did not match" match "notafloat" with | StringPatterns.IsFloat _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsDecimal should match valid decimal strings`` () = match "123.45" with | StringPatterns.IsDecimal d -> d |> should equal 123.45M | _ -> failwith "Did not match" match "notadecimal" with | StringPatterns.IsDecimal _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsDateTime should match valid DateTime strings`` () = let dateTimeStr = "2021-01-01T12:00:00Z" let expectedDateTime = DateTime(2021, 1, 1, 12, 0, 0, DateTimeKind.Utc) match dateTimeStr with | StringPatterns.IsDateTime dt -> dt |> should equal expectedDateTime | _ -> failwith "Did not match" match "notadatetime" with | StringPatterns.IsDateTime _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsDateTimeOffset should match valid DateTimeOffset strings`` () = let dtoStr = "2021-01-01T12:00:00Z" let expectedDto = DateTimeOffset(2021, 1, 1, 12, 0, 0, TimeSpan.Zero) match dtoStr with | StringPatterns.IsDateTimeOffset dto -> dto |> should equal expectedDto | _ -> failwith "Did not match" match "notadatetimeoffset" with | StringPatterns.IsDateTimeOffset _ -> failwith "Should not have matched" | _ -> () // Expected [] let ``StringPatterns.IsGuid should match valid GUID strings`` () = let guidStr = "d3b07384-d9a0-4c19-9a0c-0305e1b1c8f2" let expectedGuid = Guid.Parse(guidStr) match guidStr with | StringPatterns.IsGuid g -> g |> should equal expectedGuid | _ -> failwith "Did not match" match "notaguid" with | StringPatterns.IsGuid _ -> failwith "Should not have matched" | _ -> () // Expected ================================================ FILE: test/Falco.Tests/WebApplicationTests.fs ================================================ module Falco.Tests.WebApplication open System open System.Collections.Generic open Xunit open FsUnit.Xunit open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Routing open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Logging open Falco open Falco.Routing type IFakeIntService = abstract member GetValue : unit -> int type IFakeBoolService = abstract member GetValue : unit -> bool type FakeIntService() = interface IFakeIntService with member _.GetValue() = 42 type FakeBoolService() = interface IFakeBoolService with member _.GetValue() = true // ----------------- // Test Helpers // ----------------- let emptyHandler : HttpHandler = Response.ofPlainText "" let createTestBuilder() = let bldr = WebApplication.CreateBuilder([||]) bldr.Services.AddRouting() |> ignore bldr let createTestApp() = let builder = createTestBuilder() builder.Build() // ----------------- // WebApplicationBuilder Extensions // ----------------- [] let ``AddConfiguration should apply configuration builder function`` () = let builder = createTestBuilder() let mutable functionCalled = false builder.AddConfiguration(fun config -> functionCalled <- true config ) |> ignore functionCalled |> should equal true [] let ``AddConfiguration should modify configuration`` () = let builder = createTestBuilder() builder.AddConfiguration(fun config -> config.AddInMemoryCollection([ KeyValuePair("TestKey", "TestValue") ]) ) |> ignore let app = builder.Build() app.Configuration.["TestKey"] |> should equal "TestValue" [] let ``AddLogging should apply logging builder function`` () = let builder = createTestBuilder() let mutable functionCalled = false builder.AddLogging(fun logging -> functionCalled <- true logging ) |> ignore functionCalled |> should equal true [] let ``AddLogging should configure logging`` () = let builder = createTestBuilder() builder.AddLogging(fun logging -> logging.ClearProviders().SetMinimumLevel(LogLevel.Warning) ) |> ignore let app = builder.Build() let logger = app.Services.GetRequiredService>() logger |> should not' (be Null) [] let ``AddServices should register services`` () = let builder = createTestBuilder() builder.AddServices(fun config services -> services.AddSingleton("test-service") ) |> ignore let app = builder.Build() let service = app.Services.GetRequiredService() service |> should equal "test-service" [] let ``AddServices should receive configuration`` () = let builder = createTestBuilder() builder.Configuration.["TestKey"] <- "TestValue" let mutable receivedConfig : IConfiguration = null builder.AddServices(fun config services -> receivedConfig <- config services ) |> ignore receivedConfig |> should not' (be Null) receivedConfig.["TestKey"] |> should equal "TestValue" [] let ``AddServicesIf should apply when predicate is true`` () = let builder = createTestBuilder() builder.AddServicesIf(true, fun _ services -> services.AddSingleton("conditional") ) |> ignore let app = builder.Build() let service = app.Services.GetRequiredService() service |> should equal "conditional" [] let ``AddServicesIf should not apply when predicate is false`` () = let builder = createTestBuilder() builder.AddServicesIf(false, fun _ services -> services.AddSingleton("should-not-exist") ) |> ignore let app = builder.Build() let service = app.Services.GetService() service |> should be Null [] let ``AddServices and AddServicesIf can be chained`` () = let builder = createTestBuilder() builder .AddServices(fun _ services -> services.AddSingleton(FakeIntService())) .AddServicesIf(true, fun _ services -> services.AddSingleton("text")) .AddServicesIf(false, fun _ services -> services.AddSingleton(FakeBoolService())) |> ignore let app = builder.Build() app.Services.GetRequiredService() |> should be ofExactType app.Services.GetRequiredService() |> should equal "text" app.Services.GetService() |> should be null // ----------------- // IApplicationBuilder Extensions // ----------------- [] let ``IApplicationBuilder.UseIf should apply middleware when predicate is true`` () = let app = createTestApp() let mutable middlewareCalled = false (app :> IApplicationBuilder).UseIf(true, fun appBuilder -> middlewareCalled <- true appBuilder ) |> ignore middlewareCalled |> should equal true [] let ``IApplicationBuilder.UseIf should not apply middleware when predicate is false`` () = let app = createTestApp() let mutable middlewareCalled = false (app :> IApplicationBuilder).UseIf(false, fun appBuilder -> middlewareCalled <- true appBuilder ) |> ignore middlewareCalled |> should equal false [] let ``IApplicationBuilder.UseFalco should register endpoints`` () = let app = createTestApp() let endpoints = [ route GET "/" emptyHandler route POST "/users" emptyHandler ] app.UseRouting().UseFalco(endpoints) |> ignore let mutable sum = 0 for ds in (app :> IEndpointRouteBuilder).DataSources do sum <- sum + ds.Endpoints.Count sum |> should equal endpoints.Length [] let ``IApplicationBuilder.UseFalcoExceptionHandler should register exception handler`` () = let builder = createTestBuilder() let app = builder.Build() let exceptionHandler : HttpHandler = fun ctx -> ctx.Response.StatusCode <- 500 Response.ofPlainText "Error occurred" ctx (app :> IApplicationBuilder).UseFalcoExceptionHandler(exceptionHandler) |> ignore // This is hard to verify without actually triggering an exception // Just verify it doesn't throw true |> should equal true // ----------------- // WebApplication Extensions // ----------------- [] let ``WebApplication.UseIf should apply when predicate is true`` () = let app = createTestApp() let mutable middlewareCalled = false app.UseIf(true, fun appBuilder -> middlewareCalled <- true appBuilder ) |> ignore middlewareCalled |> should equal true [] let ``WebApplication.UseIf should not apply when predicate is false`` () = let app = createTestApp() let mutable middlewareCalled = false app.UseIf(false, fun appBuilder -> middlewareCalled <- true appBuilder ) |> ignore middlewareCalled |> should equal false // ----------------- // IEndpointRouteBuilder Extensions // ----------------- [] let ``UseFalcoEndpoints should add endpoints to data source`` () = let builder = createTestBuilder() let app = builder.Build() app.UseRouting() |> ignore app.UseEndpoints(fun endpoints -> let falcoEndpoints = [ route GET "/" emptyHandler route POST "/users" emptyHandler ] endpoints.UseFalcoEndpoints(falcoEndpoints) |> ignore ) |> ignore (app :> IEndpointRouteBuilder).DataSources |> Seq.tryFind (fun ds -> ds :? FalcoEndpointDataSource) |> Option.isSome |> should equal true [] let ``UseFalcoEndpoints should create new data source if not registered`` () = let builder = createTestBuilder() let app = builder.Build() app.UseRouting() |> ignore app.UseEndpoints(fun endpoints -> endpoints.UseFalcoEndpoints([ route GET "/" emptyHandler ]) |> ignore ) |> ignore (app :> IEndpointRouteBuilder).DataSources |> Seq.tryFind (fun ds -> ds :? FalcoEndpointDataSource) |> Option.isSome |> should equal true [] let ``UseFalcoEndpoints should reuse registered data source`` () = let builder = createTestBuilder() let dataSource = FalcoEndpointDataSource() builder.Services.AddSingleton(dataSource) |> ignore let app = builder.Build() app.UseRouting() |> ignore app.UseEndpoints(fun endpoints -> endpoints.UseFalcoEndpoints([ route GET "/test" emptyHandler ]) |> ignore ) |> ignore (app :> IEndpointRouteBuilder).DataSources |> Seq.tryFind (fun ds -> ds :? FalcoEndpointDataSource) |> Option.isSome |> should equal true // ----------------- // HttpContext Extensions // ----------------- [] let ``HttpContext.Plug should resolve registered service`` () = let builder = createTestBuilder() builder.Services.AddSingleton("test-dependency") |> ignore let app = builder.Build() let ctx = DefaultHttpContext() ctx.RequestServices <- app.Services let dependency = ctx.Plug() dependency |> should equal "test-dependency" [] let ``HttpContext.Plug should throw on missing service`` () = let app = createTestApp() let ctx = DefaultHttpContext() ctx.RequestServices <- app.Services (fun () -> ctx.Plug() |> ignore) |> should throw typeof [] let ``HttpContext.Plug should resolve different service types`` () = let builder = createTestBuilder() builder.Services.AddSingleton(FakeIntService()) |> ignore builder.Services.AddSingleton("text") |> ignore builder.Services.AddSingleton(FakeBoolService()) |> ignore let app = builder.Build() let ctx = DefaultHttpContext() ctx.RequestServices <- app.Services ctx.Plug() |> should be ofExactType ctx.Plug() |> should equal "text" ctx.Plug() |> should be ofExactType // ----------------- // Integration Tests // ----------------- [] let ``Full pipeline with builder and app extensions`` () = let builder = createTestBuilder() builder .AddConfiguration(fun config -> config.AddInMemoryCollection([ KeyValuePair("AppName", "TestApp") ]) ) .AddServices(fun config services -> services.AddSingleton(config.["AppName"]) ) |> ignore let app = builder.Build() app .UseRouting() .UseFalco([ route GET "/" (fun ctx -> let appName = ctx.Plug() Response.ofPlainText appName ctx ) ]) |> ignore let appName = app.Services.GetRequiredService() appName |> should equal "TestApp" [] let ``Multiple UseFalco calls should accumulate endpoints`` () = let app = createTestApp() app.UseRouting() |> ignore app.UseFalco([ route GET "/first" emptyHandler ]) |> ignore app.UseFalco([ route POST "/second" emptyHandler ]) |> ignore let mutable sum = 0 for ds in (app :> IEndpointRouteBuilder).DataSources do sum <- sum + ds.Endpoints.Count sum |> should equal 2