getting started
this guide walks through senro's guestbook, the app the framework was first built against. by the end you will know how a senro app is laid out, where each of its decisions lives, and what senro supplies without being asked. it assumes you can read kanso; the tour covers what you need. to start an app of your own instead, the generators write one.
what you need
three things: kanso, built from its repository with cargo build --release; a checkout of senro; and a Postgres server. senro's file .kanso-version names the kanso commit it is tested against. the guestbook expects Postgres on 127.0.0.1:54329 with the user postgres and no password, which is what senro's own specs use.
the shape of an app
an app is a directory. its entry, main.kso, holds the statements that start the app, and each other file beside it is a module. the guestbook has a module per resource (entries, account, session), one for its model (signatures), one for its migrations, and a views directory of templates. the entry imports them all and asks for three conventions.
import "../../jobs"
import "../../kata"
import "../../mail"
import "../../models"
import "../../pg"
import "../../routes"
import "../../web"
import "./account"
import "./account_mailer"
import "./confirmations"
import "./entries"
import "./home"
import "./migrations"
import "./removal"
import "./resets"
import "./session"
import "./signatures"
import "./unlocks"
import "./views"
derive kata/views
derive models/tables
derive routes/rest
# The components whose actions run on the server.
served = { "removal":(web/component removal/act removal/view) }
acting = web/components routes entries/secret served
bound_of = session/signed_against
served_routes = web/requiring_sign_in acting entries/secret bound_of
connected = pg/connect "127.0.0.1" 54329 "postgres" "postgres" ""
# The server, with a worker beside it that sends the mail the app queues.
serving = web/serve 3000 served_routes connected
connected
.> (db -> models/migrated db "guestbook" migrations/all)
.> (db -> models/compared db schema)
.> (db -> pg/close db)
.> (_ -> jobs/beside connected "mail" mail/performers serving)
each derive line names a function the compiler runs during the build. kata/views compiles the templates, models/tables folds the migrations into a schema and writes each model's reader, and routes/rest places the actions. their output is ordinary kanso, added to the entry before anything is checked; kanso derived examples/guestbook prints it.
the last statement is the app: connect to Postgres, create the tables if they are missing and compare them with the schema, then serve on port 3000. web/serve answers each connection in a fiber of its own and lends each request a database session from a pool. bytes that are not a request, such as a line without a method and a path, are answered 400 Bad Request and the connection is closed.
the schema
the schema is the migrations, oldest first. a migration is a value built from a fixed set of changes, so it cannot break when a model changes later, and there is no separate schema file to keep in step with it.
import "../../models"
# The guestbook's schema, as the changes that built it, oldest first. The
# models derivation folds these into the schema the app is compiled against.
pub all = [
signed
joined
emailed
confirmable
counted
lockable
tracked
arrived
arrived_before
arrived_from
arrived_from_before
pictured
]
signed = models/create_table "signatures" [id name message]
id = models/column "id" "serial"
joined = models/create_table "accounts" [id name digest]
# The address a reset link is sent to.
emailed = models/add_column "accounts" email
# When the address was confirmed, or empty until it is.
confirmable = models/add_column "accounts" confirmed
# How many wrong passwords in a row the account has had, and when too many
# of them locked it, or empty while it is open.
counted = models/add_column "accounts" failures
lockable = models/add_column "accounts" locked
# How many times the account has signed in, and when and from where the
# latest sign-in and the one before it came, as Devise's trackable keeps them.
tracked = models/add_column "accounts" sign_in_count
arrived = models/add_column "accounts" current_sign_in_at
arrived_before = models/add_column "accounts" last_sign_in_at
arrived_from = models/add_column "accounts" current_sign_in_ip
arrived_from_before = models/add_column "accounts" last_sign_in_ip
# The key of the photo a signer chose, or empty for an entry without one.
pictured = models/add_column "signatures" photo
name = models/column "name" "text"
message = models/column "message" "text"
digest = models/column "digest" "text"
email = models/column "email" "text"
confirmed = models/column "confirmed" "text"
failures = models/column "failures" "integer not null default 0"
locked = models/column "locked" "text not null default ''"
sign_in_count = models/column "sign_in_count" "integer not null default 0"
current_sign_in_at = models/column "current_sign_in_at" blank_text
last_sign_in_at = models/column "last_sign_in_at" blank_text
current_sign_in_ip = models/column "current_sign_in_ip" blank_text
last_sign_in_ip = models/column "last_sign_in_ip" blank_text
photo = models/column "photo" blank_text
blank_text = "text not null default ''"
a model
a model lives in a module named for its table. the models derivation declares the record in signatures, as signature with a field for each column of the signatures table, so the module never lists them, and a field the table lacks cannot be read. the functions in the module are the model's policies and its rules.
import "../../models"
import "std/text"
# A signature is a row of the `signatures` table. This module is named for
# the table, so senro/models declares `signature` here with a field for each
# column, and writes `signature_from`, which builds one from a row.
#
# Only the person who signed an entry may remove it. The models derivation
# reads this policy and writes `signature_destroyed`, which asks it before it
# deletes anything, so there is no other way to remove a signature.
pub fn destroy? viewer s
s.name == viewer
# Anyone may sign the book.
pub fn create? _ _
true
# A signature needs a name and a message, and a message short enough to
# read at a glance.
pub fn problems s
models/problems [
(models/rule "name" (text/trim s.name == "") "Say who you are.")
(models/rule "message" (text/trim s.message == "") "Say something.")
(models/rule "message" (length s.message > 280) "Keep it under 280.")
]
from these, models/tables writes signature_from, which builds a signature from a row, and checked writes such as signature_destroyed, which asks destroy? before it deletes.
actions and routes
an action is a public function in a resource's module. it takes the request and a database session and answers an effect that yields the response. its name places it: index is GET /entries, create is POST /entries, and the other five of a resource's seven actions follow Rails. a public function with any other name is refused where it is declared.
import "../../models"
import "../../pg"
import "../../web"
import "./removal"
import "./signatures"
import "./views"
import "std/list"
# The session says who is signed in, and carries a notice across the
# redirect that follows a signature or a sign-in; the page that shows the
# notice clears it.
pub fn index req db
s = web/session req secret
pg/query db newest [] .> (r -> shown req r.rows s (filled "" {}) 200)
# A development secret. A deployed guestbook signs its sessions with its own.
pub secret = "the guestbook's development secret"
# A signature is made through its model, whose rules decide whether it is
# fit to keep. It is signed with the name of the account signed in. The
# fields go to Postgres as parameters, so what a visitor types is stored as
# text and never read as SQL.
pub fn create req db
viewer = web/viewer req secret
f = put (web/form req.body) "name" viewer
models/created viewer db signatures/signature_model f
.> (w -> signed req db w)
create reads who is signed in from the session, which is a cookie signed with the app's secret. it never sees a visitor who is not signed in: the entry serves every action that its module does not open through web/requiring_sign_in, which sends that visitor to the sign-in page first (the sessions guide has the details). the form goes through the model: models/created checks the policy and the rules and answers either the new row or the problems to show.
a view
a view is a kata template. its first line names what it takes, and the rest is indented markup, closed for you: a tag with its classes and attributes, then its text. {viewer} interpolates and escapes, = expr writes a value escaped and == expr writes it raw, and the control lines are if and for. kata/views compiles each template to kanso functions and checks each field it reads. signed_view, which entries/index calls, describes the page as data, so senro's page script can render it in the browser with the same template compiled to wasm; a first request gets it rendered as HTML.
takes signatures form flash removals viewer
header
h1 Guestbook
p.lede Leave a line for whoever comes next.
if viewer == ""
nav.door
a href="/session/new" Sign in
a href="/account/new" Make an account
else
form.out method="post" action="/session"
input type="hidden" name="_method" value="DELETE"
span Signed in as {viewer}.
a href="/account/edit" Your account
button Sign out
if flash != ""
p.flash = flash
if viewer != ""
form.sign method="post" action="/entries" enctype="multipart/form-data"
textarea name="message" placeholder="say something" required="required" = form.message
if form.problems["message"] != none
p.problem = form.problems["message"]
label
span A photo, if you like
input type="file" name="photo" accept="image/*"
button Sign
ol.entries
for e in signatures
li
p.message = e.message
if e.photo != ""
img.photo src="/senro/blobs/{e.photo}" alt="a photo from {e.name}"
p.who = e.name
== removals["{e.id}"]
running it
from the root of the senro checkout:
kanso run examples/guestbook
open http://localhost:3000, make an account and sign the book. kanso run builds at kanso's development tier; kanso build examples/guestbook makes a release binary, which stretches passwords about fourteen times faster.