routing
a senro app declares no routes. each public function in one of its modules is an action, and the module's name and the function's name place it. this guide covers the seven actions, how an id reaches an action, singular resources, resources nested under another, naming a route from code, and keeping an old URL working.
the seven actions
a resource is a module, and its actions are the seven Rails gives a resource. for a module named posts:
| action | request | what it is for |
|---|---|---|
posts/index | GET /posts | every post |
posts/new | GET /posts/new | a form for a new one |
posts/create | POST /posts | make one from the form |
posts/show | GET /posts/:id | one post |
posts/edit | GET /posts/:id/edit | a form to change it |
posts/update | PATCH /posts/:id | change it from the form |
posts/destroy | DELETE /posts/:id | remove it |
a module named home is the exception: home/index answers GET /. a browser's form can only send GET and POST, so a form that means PATCH or DELETE posts a hidden field, _method, and the server reads the request as that method. a public function with any other name is refused where it is declared, so a misspelled action never becomes a dead route.
ids
an action whose route has :id takes it as a third argument. annotated as an int, it reaches the action as one, and a segment that is not a number is not that route.
pub fn index _ _
"every post"
pub fn show _ _ id:int
"post {id}"
the annotation matters for more than conversion. GET /posts/new and GET /posts/:id would both answer /posts/new if id could be any word, and senro refuses the pair.
pub fn new _
"a blank form"
pub fn show _ id
"post {id}"
error[derive]: `GET /posts/:id` and `GET /posts/new` (posts/new) answer the same request: annotate `id` as `int` so a word is not taken for an id
--> ./posts.kso:4:8
4 | pub fn show _ id
^
singular resources
some resources have one of them per visitor: a profile, a session, a cart. a module whose show, edit, update and destroy take no id is a singular resource, as Rails has them. its routes drop the :id, and it has no index.
import "../../../routes"
pub type profile
name
pub fn show _ _
"your profile is at {routes/path_to (profile "ada")}"
pub fn edit _ _
"edit it"
pub fn update _ _
"saved"
with the posts module above, the app's routes are these:
["GET /posts" "GET /posts/:id" "GET /profile" "GET /profile/edit" "PATCH /profile"]
the guestbook's sign-in is one: session/new is the form at GET /session/new, session/create signs in at POST /session, and session/destroy signs out at DELETE /session.
nested resources
a comment belongs to a post, and its URL can say so. a module whose actions each take the parent's id right after the request and the database is nested under the parent: comments/index req db post_id answers GET /posts/:post_id/comments, and comments/show req db post_id id answers GET /posts/:post_id/comments/:id. the parameter's name places the route, as the action's name does: post_id puts it under /posts. an annotation reads the same way as it does for id, so a post_id:int that is not a number is no route.
import "std/net/http"
# Each action takes the post's id, so the routes nest under it:
# `comments/index` answers GET /posts/:post_id/comments. Removing one needs a
# sign-in, so the server sends that request to sign in first.
pub without_sign_in = ["index" "create" "show"]
pub fn index _ _ post_id:int
effect (http/text_page "the comments on post {post_id}")
pub fn create _ _ post_id:int
effect (http/text_page "a comment made on post {post_id}")
pub fn show _ _ post_id:int id:int
effect (http/text_page "comment {id} on post {post_id}")
pub fn destroy _ _ post_id:int id:int
effect (http/text_page "comment {id} gone from post {post_id}")
the server hands the action the parent's id first and its own id second. every action of a nested module takes the parent's id in that place, and one that does not is refused where it is declared. nesting goes one level deep, which is what Rails' guides advise: a comment's URL needs its post and nothing above it, so a module nested under comments answers under /comments/:comment_id.
a nested route named from code takes the parent's id before its own: path_to "comments/show" 3 4 is /posts/3/comments/4, and a call that leaves the parent out is refused with the form it should take. path_to comment reads the parent's id from the record's own post_id, so a nested record has that field.
# A comment, which names the post it is under, so its path can.
pub type comment
id
post_id
body
# The first comment on post 3, for the spec to place.
pub first = comment 4 3 "first"
pub fn index _ _ post_id:int
"the comments on post {post_id}"
pub fn show _ _ post_id:int id:int
"comment {id} on post {post_id}"
naming a route
routes/path_to answers a route's path, so a link or a redirect never spells a path by hand. it takes an action's name, with an id when the route has one, or a record whose type is declared in a module with a show action.
import "../../../routes"
pub type post
id
title
pub fn index _
"every post: {routes/path_to "posts/index"}"
pub fn show _ id:int
"post {id} is at {routes/path_to (post id "hello")}"
post 3 is at /posts/3
every post: /posts
/posts/7
/
both forms are checked while the app compiles. a name that is no route is refused at the call, and so is an id given to a route without one or left off a route that needs it.
keeping an old URL
an app that must keep an old path working declares legacy_route_aliases in one of its modules: a map from the old method and path to the action that answers it now. the name says what it is, a measure for compatibility, and each alias answers with a permanent redirect (308) to its action's route, so a resource keeps one URL.
pub legacy_route_aliases = {
"GET /entry/:id":"entries/show"
"GET /signbook":"entries/index"
"POST /sign":"entries/create"
}
an alias must name a route, take :id as its second segment exactly when its route does, use its route's method, and answer no request that a route or another alias answers. each of those mistakes is a compile error at the declaration.
a query string
a route is found by its path alone, so /search?q=two%20words&page=3 reaches the action GET /search names. the action reads the query with web/query req, which answers it as a form, and web/value reads a field from it as it reads a posted one. a field the query does not carry reads as "". the server sets the query aside when it parses the request, under a header name of its own, and a header of that name sent by the client is replaced, so a client cannot pass a query off as one it did not send.
import "../../../web"
import "std/net/http"
# What the action read from the query string.
pub fn index req _
q = web/query req
shown = "q={web/value q "q"} page={web/value q "page"}"
effect (http/text_page "{req.path}: {shown}")
senro's spec asks for that path, for /search with no query, and for /search with a forged header, and the action answers:
/search: q=two words page=3
/search: q= page=
/search: q= page=
answering JSON
an action can answer JSON as well as a page, as Rails' respond_to lets one. web/wants_json? req says whether the request asks for it: its path ends .json, or its Accept header names application/json. the route is found by the path without the .json, so /notes.json and /notes/3.json reach the actions /notes and /notes/3 do. web/json status value answers the value as JSON, as render json: does. the value is usually a map a model gives, or a list of them; the generators guide shows the scaffold's two actions that answer this way.
# `answer` as JSON with `status`, as Rails' `render json:` answers: a map or
# a list of maps, such as a model's `note_attributes` gives.
pub fn json status answer
http/respond status [json_type] "{json/encode answer}\n"
json_type = "content-type: application/json; charset=utf-8"
# Whether the request asks for JSON, as Rails' `respond_to` reads `format`:
# its path ends `.json`, or its Accept header names `application/json`. The
# route is matched without the `.json`, so `/notes.json` and `/notes/3.json`
# reach the actions `/notes` and `/notes/3` do.
pub fn wants_json? req
accepted = blank_if_none req.headers["accept"]
ends = text/ends_with? req.path ".json"
ends or length (text/split accepted "application/json") > 1
what the derivation writes
derive routes/rest writes one constant, routes, a map from each method and path to its action, and the arms that make path_to work for records. kanso derived prints both. the guestbook's table is the line that begins routes = in its output.