The previous article might have left you with an erroneous impression that WebSharper sitelets only support a static set of URLs. This is not the case. In fact, the user-defined Action type can be an arbitrary data structure that maps to a URL. This comes particularly handy when designing a blogging website, since the set of blogs and their URLs is not statically bounded. Something like this would do:
type Id = int | |
type Action = | |
| ShowRecentBlogs | |
| CreateBlog | |
| ReadBlog of Id | |
| UpdateBlog of Id | |
| DeleteBlog of Id |
Now you would need to write a router, which is roughly a bijection between Action values and URLs. A boring way to do so might look like this:
let Router : Router<Action> = | |
let route = function | |
| GET (_, SPLIT_BY '/' []) -> | |
Some ShowRecentBlogs | |
| GET (_, SPLIT_BY '/' ["create"]) -> | |
Some CreateBlog | |
| GET (_, SPLIT_BY '/' ["read"; INT id]) -> | |
Some (ReadBlog id) | |
| GET (_, SPLIT_BY '/' ["update"; INT id]) -> | |
Some (UpdateBlog id) | |
| GET (_, SPLIT_BY '/' ["delete"; INT id]) -> | |
Some (DeleteBlog id) | |
| _ -> | |
None | |
let link x = | |
let uri = | |
match x with | |
| ShowRecentBlogs -> "/" | |
| CreateBlog -> "/create" | |
| ReadBlog x -> sprintf "/read/%i" x | |
| UpdateBlog x -> sprintf "/update/%i" x | |
| DeleteBlog x -> sprintf "/delete/%i" x | |
Some (Uri(uri, UriKind.Relative)) | |
Router.New route link |
Writing routers by hand you get full control – you can inspect an Http.Request value when routing, and generate any URL when linking. However writing the above code is not much fun. If you do not particularly care for the exact URLs, there is a much shorter way to get there:
let Router : Router<Action> = Router.Infer() |
The Infer function inspects the Action type by reflection to derive a router automatically. It is smart enough to handle records, unions, most scalar types, lists, and options. In our case it derives the following mapping:
ShowRecentBlogs <=> /ShowRecentBlogs | |
CreateBlog <=> /CreateBlog | |
ReadBlog $x <=> /ReadBlog/$x | |
UpdateBlog $x <=> /UpdateBlog/$x | |
DeleteBlog $x <=> /DeleteBlog/$x | |
let Router : Router<Action> = | |
Router.Table [ShowRecentBlogs, "/"] | |
<|> Router.Infer() |
This is much better: 80% of the benefit with 20% of the work.
Now that URL routing is out of the way, you can finally get down to the blog service itself. I present the most interesting thing first, the controller:
let Controller = | |
let handle = function | |
| ShowRecentBlogs -> | |
let blogs = Model.WithBlogs <| fun db -> db.GetRecentBlogs() | |
View.ShowRecentBlogs blogs | |
| CreateBlog -> | |
View.CreateBlog () | |
| ReadBlog id -> | |
let blog = Model.WithBlogs <| fun db -> db.ReadBlog id | |
match blog with | |
| Some blog -> | |
View.ReadBlog blog | |
| None -> | |
Content.NotFound | |
| UpdateBlog id -> | |
let blog = Model.WithBlogs <| fun db -> db.ReadBlog id | |
match blog with | |
| None -> Content.Redirect Action.CreateBlog | |
| Some blog -> View.UpdateBlog blog | |
| DeleteBlog id -> | |
let blog = Model.WithBlogs <| fun db -> | |
let blog = db.ReadBlog id | |
if blog.IsSome then | |
db.DeleteBlog id | |
|> ignore | |
blog | |
match blog with | |
| Some blog -> | |
View.BlogDeleted blog | |
| None -> | |
Content.ServerError | |
{ Handle = handle } |
The controller simply handles actions (Action values) and returns responses (Content values), no surprises here. Together with a router, the controller forms a sitelet, a self-contained website ready to go by itself or be embedded in a bigger application:
let Main : Sitelet<Action> = | |
{ | |
Controller = Controller | |
Router = Router | |
} |
For the model in this example, you can use the simplest thing that can possibly work in a development environment, namely a static mutable variable (see the listing at the end of the article). Now, views are quite straightforward as well. The only thing of interest is the use of WebSharper Formlets (client-side form combinators) to build the UI for editing and creating the blogs. I build a simple control that renders the form and talks to the model directly over RPC, redirecting to the homepage when done:
module Client = | |
open IntelliFactory.WebSharper | |
open IntelliFactory.WebSharper.Formlet | |
[<Rpc>] | |
let CreateBlog blog = | |
Model.WithBlogs <| fun db -> | |
db.CreateBlog { blog with Date = DateTime.UtcNow } | |
[<Rpc>] | |
let UpdateBlog blog = | |
Model.WithBlogs <| fun db -> | |
db.UpdateBlog { blog with Date = DateTime.UtcNow } | |
[<Inline "document.location = $location">] | |
let Redirect (location: string) = X<unit> | |
type BlogControl(homeLocation: string, blog: option<Blog>) = | |
inherit Web.Control() | |
new () = new BlogControl("?", None) | |
[<JavaScript>] | |
override this.Body = | |
let (id, title, summary, text) = | |
match blog with | |
| None -> (0, "", "", "") | |
| Some b -> (b.Id, b.Title, b.Summary, b.Text) | |
let titleForm = | |
Controls.Input title | |
|> Enhance.WithTextLabel "Title" | |
|> Validator.IsNotEmpty "Required." | |
let summaryForm = | |
Controls.TextArea summary | |
|> Enhance.WithTextLabel "Summary" | |
|> Validator.IsNotEmpty "Summary is required." | |
let textForm = | |
Controls.TextArea text | |
|> Enhance.WithTextLabel "Text" | |
|> Validator.IsNotEmpty "Text is required" | |
Formlet.Yield (fun title summary text -> | |
{ | |
Id = id | |
Date = DateTime.UtcNow | |
Title = title | |
Summary = summary | |
Text = text | |
}) | |
<*> titleForm | |
<*> summaryForm | |
<*> textForm | |
|> Enhance.WithLabelConfiguration { | |
Layout.LabelConfiguration.Default with | |
VerticalAlign = Layout.VerticalAlign.Top | |
} | |
|> Enhance.WithSubmitAndResetButtons | |
|> Enhance.WithFormContainer | |
|> Formlet.Run (fun newBlog -> | |
match blog with | |
| None -> ignore (CreateBlog newBlog) | |
| Some _ -> ignore (UpdateBlog newBlog) | |
Redirect homeLocation) |
Time to try it out:
And here is the complete code listing:
module My.Blogs | |
open System | |
open System.Collections.Generic | |
open System.Web | |
open IntelliFactory.WebSharper.Sitelets | |
type Id = int | |
type Html = string | |
type Blog = | |
{ | |
Id : Id | |
Title : string | |
Date : DateTime | |
Summary : Html | |
Text : Html | |
} | |
type Action = | |
| ShowRecentBlogs | |
| CreateBlog | |
| ReadBlog of Id | |
| UpdateBlog of Id | |
| DeleteBlog of Id | |
module Model = | |
type Blogs = | |
{ | |
GetRecentBlogs : unit -> seq<Blog> | |
CreateBlog : Blog -> Id | |
UpdateBlog : Blog -> bool | |
ReadBlog : Id -> option<Blog> | |
DeleteBlog : Id -> bool | |
} | |
let private data = | |
let d = Dictionary() | |
d.[0] <- | |
{ | |
Id = 0 | |
Title = "Blog-1" | |
Date = System.DateTime.Now | |
Summary = "summary.." | |
Text = "text.." | |
} | |
d.[1] <- | |
{ | |
Id = 1 | |
Title = "Blog-2" | |
Date = System.DateTime.Now | |
Summary = "summary.." | |
Text = "text.." | |
} | |
d :> IDictionary<_,_> | |
let WithBlogs action = | |
lock data <| fun () -> | |
let d = data | |
action { | |
GetRecentBlogs = fun blog -> | |
d.Values | |
|> Seq.toArray | |
|> Array.rev | |
:> seq<_> | |
CreateBlog = fun blog -> | |
let k = d.Count | |
d.[k] <- { blog with Id = k } | |
k | |
UpdateBlog = fun blog -> | |
if d.ContainsKey blog.Id then | |
d.[blog.Id] <- blog | |
true | |
else | |
false | |
ReadBlog = fun id -> | |
match d.TryGetValue id with | |
| true, blog -> Some blog | |
| _ -> None | |
DeleteBlog = fun id -> | |
d.Remove id | |
} | |
module Client = | |
open IntelliFactory.WebSharper | |
open IntelliFactory.WebSharper.Formlet | |
[<Rpc>] | |
let CreateBlog blog = | |
Model.WithBlogs <| fun db -> | |
db.CreateBlog { blog with Date = DateTime.UtcNow } | |
[<Rpc>] | |
let UpdateBlog blog = | |
Model.WithBlogs <| fun db -> | |
db.UpdateBlog { blog with Date = DateTime.UtcNow } | |
[<Inline "document.location = $location">] | |
let Redirect (location: string) = X<unit> | |
type BlogControl(homeLocation: string, blog: option<Blog>) = | |
inherit Web.Control() | |
new () = new BlogControl("?", None) | |
[<JavaScript>] | |
override this.Body = | |
let (id, title, summary, text) = | |
match blog with | |
| None -> (0, "", "", "") | |
| Some b -> (b.Id, b.Title, b.Summary, b.Text) | |
let titleForm = | |
Controls.Input title | |
|> Enhance.WithTextLabel "Title" | |
|> Validator.IsNotEmpty "Required." | |
let summaryForm = | |
Controls.TextArea summary | |
|> Enhance.WithTextLabel "Summary" | |
|> Validator.IsNotEmpty "Summary is required." | |
let textForm = | |
Controls.TextArea text | |
|> Enhance.WithTextLabel "Text" | |
|> Validator.IsNotEmpty "Text is required" | |
Formlet.Yield (fun title summary text -> | |
{ | |
Id = id | |
Date = DateTime.UtcNow | |
Title = title | |
Summary = summary | |
Text = text | |
}) | |
<*> titleForm | |
<*> summaryForm | |
<*> textForm | |
|> Enhance.WithLabelConfiguration { | |
Layout.LabelConfiguration.Default with | |
VerticalAlign = Layout.VerticalAlign.Top | |
} | |
|> Enhance.WithSubmitAndResetButtons | |
|> Enhance.WithFormContainer | |
|> Formlet.Run (fun newBlog -> | |
match blog with | |
| None -> ignore (CreateBlog newBlog) | |
| Some _ -> ignore (UpdateBlog newBlog) | |
Redirect homeLocation) | |
module View = | |
open IntelliFactory.Html | |
open IntelliFactory.WebSharper.Formlet | |
let ( => ) a b = | |
A [HRef b] -< [Text a] | |
let Page title makeBody = | |
PageContent <| fun ctx -> | |
{ Page.Default with | |
Title = Some title | |
Body = | |
[ | |
UL [ | |
LI ["Home" => ctx.Link ShowRecentBlogs] | |
LI ["Post" => ctx.Link CreateBlog] | |
] | |
Div (makeBody ctx) | |
] | |
} | |
let ShowRecentBlogs (blogs: seq<Blog>) = | |
Page "News" <| fun ctx -> | |
[ | |
yield H1 [Text "News"] | |
for b in blogs do | |
yield A [HRef (ctx.Link (ReadBlog b.Id))] | |
-< [H2 [Text b.Title]] | |
yield P [Text b.Summary] | |
yield | |
UL [ | |
LI [P ["Edit" => ctx.Link (UpdateBlog b.Id)]] | |
LI [P ["Delete" => ctx.Link (DeleteBlog b.Id)]] | |
] | |
] | |
let ReadBlog (blog: Blog) = | |
Page blog.Title <| fun ctx -> | |
[ | |
H1 [Text blog.Title] | |
P [Text blog.Summary] | |
P [Text blog.Text] | |
UL [ | |
LI [P ["Edit" => ctx.Link (UpdateBlog blog.Id)]] | |
LI [P ["Delete" => ctx.Link (DeleteBlog blog.Id)]] | |
] | |
] | |
let BlogDeleted (blog: Blog) = | |
Page "Blog Deleted" <| fun ctx -> | |
[ | |
P [ | |
Text "Successfully deleted blog: " | |
Text blog.Title | |
] | |
] | |
let CreateBlog () = | |
Page "New Post" <| fun ctx -> | |
let home = ctx.Link Action.ShowRecentBlogs | |
[ | |
new Client.BlogControl(home, None) | |
] | |
let UpdateBlog (blog: Blog) = | |
Page "Update" <| fun ctx -> | |
let home = ctx.Link Action.ShowRecentBlogs | |
[ | |
new Client.BlogControl(home, Some blog) | |
] | |
let Controller = | |
let handle = function | |
| ShowRecentBlogs -> | |
let blogs = Model.WithBlogs <| fun db -> db.GetRecentBlogs() | |
View.ShowRecentBlogs blogs | |
| CreateBlog -> | |
View.CreateBlog () | |
| ReadBlog id -> | |
let blog = Model.WithBlogs <| fun db -> db.ReadBlog id | |
match blog with | |
| Some blog -> | |
View.ReadBlog blog | |
| None -> | |
Content.NotFound | |
| UpdateBlog id -> | |
let blog = Model.WithBlogs <| fun db -> db.ReadBlog id | |
match blog with | |
| None -> Content.Redirect Action.CreateBlog | |
| Some blog -> View.UpdateBlog blog | |
| DeleteBlog id -> | |
let blog = Model.WithBlogs <| fun db -> | |
let blog = db.ReadBlog id | |
if blog.IsSome then | |
db.DeleteBlog id | |
|> ignore | |
blog | |
match blog with | |
| Some blog -> | |
View.BlogDeleted blog | |
| None -> | |
Content.ServerError | |
{ Handle = handle } | |
let Router : Router<Action> = | |
Router.Table [ShowRecentBlogs, "/"] | |
<|> Router.Infer() | |
let Main : Sitelet<Action> = | |
{ | |
Controller = Controller | |
Router = Router | |
} |