32
loading...
This website collects cookies to deliver better user experience
If you come from PHP, Javascript this might help you understand a little bit more the F# backends or confuse you even more 😆 I'm sorry if it happens the later.
dotnet new mvc -lang F#
or dotnet new webapp
but that would be the least idiomatic F# solution since ASP.NET assumes (at least on net5.0 and lower) that you will use OOP and while that's certainly something that can be done in F# it's not the ideal solution.Saturn
A modern web framework that focuses on developer productivity, performance, and maintainability
Saturn has an opinionated and stablished way on how to do things, please check the Saturn documentation for a more normal way to do things within Saturn realm or check the SAFE stack documentation which has a more complete solution if you're looking for a production ready stack.
dotnet new --install AngelMunoz.Saturn.Templates::1.0.1
dotnet new saturn-mvc -o ProjectName
Properties/
launchSettings.json
Todos/
Controllers.fs
Models.fs
Views.fs
README.md
Views/
Home.fs
README.md
wwwroot/
css/
styles.css
js/
index.js
BaseViews.fs
Program.fs
ProjectName.fsproj
README.md
Program.fs
file.module ProjectName.Program
open Giraffe
open Saturn
open Saturn.Endpoint
open ProjectName.Todos.Controller
open ProjectName.Views
let browser =
/// pipelines are meant to configure
/// the request's headers, authorization, challenges
/// and related settings, they can be done via plugs
/// or Saturn's specific methods like `set_header`
pipeline {
plug acceptHtml
plug putSecureBrowserHeaders
plug fetchSession
set_header "x-pipeline-type" "Browser"
}
let defaultView =
/// routers are a collection of endpoints and handlers
/// you can use other verbs like post/put/patch/delete
/// and even accept parameters in the url like
/// getf "/user/%i/categories/%s" (fun (id: int) (category: string) _ (ctx: HttpContext)-> ...)
/// putf "/user/%i" (fun (id: int) _ (ctx: HttpContext)-> ...)
router {
get "/" (htmlView (Home.Index()))
get "/index.html" (redirectTo false "/")
get "/default.html" (redirectTo false "/")
}
let browserRouter =
router {
/// assigns the pipeline to configure this router's requests
pipe_through browser
/// you can either define the routes indiually like the defaultView
// or forward routes to a particular router
forward "" defaultView
/// you can forward calls to controllers as well
forward "/todos" TodoController
}
/// you can compose multiple routers in a single one
/// for example a router in charge of JSON requests or XML requests can also be defined
/// and forwarded to a particular router
let appRouter = router { forward "" browserRouter }
[<EntryPoint>]
let main args =
let app =
// this is an ASP.NET ApplicationBuilder
// here you'll find anything that you need to configure
// your server
application {
use_developer_exceptions
use_endpoint_router appRouter
use_static "wwwroot"
}
// once the app is built, just run it
run app
0
Program.fs
file we take care of the configuration, if we wanted to use CSRF, Cookies, JWT, Dependency Injection (which I don't think is needed unless your library really really needs it) authentication or authorization it would be here in the application builder.defaultView
which handles /index.html
and similar calls with Home.Index()
which is defined inside ProjectName.Views
namespace ProjectName.Views
open Giraffe.ViewEngine
open ProjectName.BaseViews
[<RequireQualifiedAccess>]
module Home =
let Index () =
let content =
[ Partials.Navbar(
leftLinks =
[ a [ _href "/todos"; _class "navbar-item" ] [
str "Check the Todos!"
] ]
)
article [ _class "page" ] [
header [] [
h1 [] [ str "Welcome to Saturn!" ]
]
p [] [
str
"""
Saturn is an F# web framework for asp.net
"""
]
] ]
Layout.Default(content, "Home")
A little lost? check out
Where I described the Giraffe.ViewEngine syntax and how can you use it to produce HTML
tag [(* attributes *)] [(* contents (XmlNodes) *)]
Index()
function is basically calling a Default(content, Title)
static method on the Layout
class if you come from MVC applications from other places it is not uncommon to use a master layout and just put your content in there.article (* attributes *) [ _class "page" ]
// content
[ header (* attributes *) []
// content
[ h1 (* attributes *) []
// content
[ str "Welcome to Saturn!" ] ]
p [] [ str
"""
Saturn is an F# web framework for asp.net
""" ]
]
We're also using a couple of helpers defined in BaseViews.fs
we will not review those for the sake of keeping this simple what we need to know is that there's a layout that we're filling with content and that there's a Navbar
partial we're using at the beginning of our content.
htmlView
from Giraffe.Core
.TodoController
which is defined in Todos/Controllers.fs
.In this section I will omit code for brevity but you will be able to see it locally if you used the dotnet template from the beginning of the post.
namespace ProjectName.Todos
(* ... open namespaces/modules ... *)
module Controller =
// Models and Views are part of the ProjectName.Todos namespace
open ProjectName.Todos
// All of our controller functions
// are isolated from the application using private
let private todos = (* ... code ... *)
let private addTodo = (* ... code ... *)
let private createTodo = (* ... code ... *)
let private showTodo = (* ... code ... *)
let private editTodo = (* ... code ... *)
let private updateTodo = (* ... code ... *)
let private deleteTodo = (* ... code ... *)
/// only TodoController is exposed which is used
/// by `forward "/todos" TodoController` in the browserRouter
let TodoController =
/// the controllers define a bunch of useful functions
/// that we can use as a convention to handle http requests
/// for a particular resource. In this case
/// we're focusing on To-do's
controller {
// GET /todos
index todos
// GET /todos/add
add addTodo
// POST /todos/add
create createTodo
// GET /todos/1
show showTodo
// GET /todos/1/edit
edit editTodo
// PUT /todos/1
update updateTodo
// DELETE /todos/1
delete deleteTodo
}
// make the Model and View modules available
open ProjectName.Todos
let private todos =
fun ctx ->
task {
// this is the most ideal MVC situation
// call a service or the model and get the information
let! todos = Model.Find()
// build the html view with the model
let view = View.Index todos
// return the rendered HTML view to the client
return! Controller.renderHtml ctx view
}
let private addTodo =
fun ctx ->
task {
// the add function is the GET request where we
// render the HTML where we will send the form
// I'm not doing CSRF to prevent XSS but here we can also pass
// the CSRF token if we had enabled it
let view = View.AddTodo()
return! Controller.renderHtml ctx view
}
let private createTodo =
fun (ctx: HttpContext) ->
task {
// since we're not using JS on the frontend we're just doing plain 'ol
// HTML views, we get the values of our todo from the POST'ed form
let title = ctx.GetFormValue("title")
let isDone = ctx.GetFormValue("isDone")
// we ensable the partial to create our To-do
let partial =
{ title = title |> Option.defaultValue ""
isDone = isDone |> Option.defaultValue "off" }
// Create the Todo
let! todo = Model.Create partial
// build the view
let view = View.TodoDetail todo
// return the HTML to the client
return! Controller.renderHtml ctx view
}
delete
function.ajax
call, there will be cases where we don't want to render completely a new HTML page so we use some kind of javascript code in the frontend (be it a library or hand written code) that will make a request to the server we can chose to act accordingly in those cases as welllet private deleteTodo =
fun (ctx: HttpContext) id ->
task {
let! _ = Model.Delete id
// set the HTMX redirect header so once we delete
// the resource we're redirected to the "/todos" page
ctx.SetHttpHeader("HX-Redirect", "/todos")
// set the status code
ctx.SetStatusCode(204)
// return an empty response
return! Controller.text ctx ""
}
Controller
let's check the Model
now.type Todo =
{ id: int
title: string
isDone: bool }
// when you bind forms from application/x-www-form-urlencoded
// the records must be marked as CLI mutable so they can be used correctly
// please bear in mind that if we were using JSON (using System.Text.JSON or Thoth.JSON)
// this attribute would not be needed
[<CLIMutable>]
type PartialTodo = { title: string; isDone: string }
/// we use require qualified access to prevent the pollution of the namespace
[<RequireQualifiedAccess>]
/// Model could also be named Services as well, it's just a word to be honest, find
/// the word that fits best for your mental model in the end this is just an API/Interface to
/// interact with your database
module Model =
open System.Threading.Tasks
open System.Linq
// fake database
let private _todos = lazy (ResizeArray())
(* Fake async services *)
let Find () =
Task.FromResult(_todos.Value |> List.ofSeq)
let FindOne (id: int) = ... Task ...
let Create (todo: PartialTodo) = ... Task ...
let Update (todo: Todo) = ... Task ...
let Delete (id: int) = ... Task ...
Home.fs
but for sake of completeness let's check the Index
view/// let's define a helper function
/// that will define a general skeleton for our page
/// think of it as a `page` partial
let private page attrs content =
[ Partials.Navbar() // prefill with the navbar
// and an article with a particular case,
// but override it if it comes inside attributes.
article [ yield! attrs; _class "page" ] content ]
/// in F# everything is very likely a function that takes a parameter
/// Our controllers, our Models and our views are no exception
/// to render our index we take a list of To-do's
let Index (todos: Todo list) : XmlNode =
let content =
page [ _class "page f-row" ] [
aside [ _class "menu" ] [
ul [ _class "menu-list" ] [
li [] [
// offer a link to add a new todo
a [ _href "/todos/add" ] [
str "Add Todo"
]
]
]
]
/// use a table (much emterprise, such demvelomper, much wow)
table [ _class "table is-bordered is-striped is-narrow is-hoverable is-fullwidth" ] [
thead [] [
th [] [ str "Id" ]
th [] [ str "Title" ]
th [] [ str "Is Done" ]
]
tbody [] [
// render our todos inside the table
for todo in todos do
tr [] [ // row
td [] [ // column
a [ _href $"/todos/{todo.id}" ] [
str $"{todo.id}"
]
]
td [] [ str todo.title ] // column
td [] [ // column
str (sprintf "%s" (if todo.isDone then "Yes" else "No"))
]
]
]
]
]
Layout.Default(content, "Todos")