Tutorial
If you don’t already have Anansi installed, check out this page.
To see which version of Anansi you have, run the following command:
$ ananc --version
Creating a basic site
Let’s make a simple forum. In a terminal, go to the directory where you want to create the project, and run:
$ ananc new mini-forum
This will create a crate called mini-forum
with the following files:
- http_errors/: Includes http error pages.
- main.rs: Has a list of apps.
- project.rs: Details project settings.
- urls.rs: Manages the routing.
The default database is Sqlite. If you want to use PostgreSQL, in Cargo.toml
, change features = ["sqlite"]
to features = ["postgres"]
. In src/project.rs
, change database!(sqlite)
to database!(postgres)
. Finally, in settings.toml
, change [databases.default]
to:
name = "mydatabase"
user = "myuser"
password = "mypassword"
address = "127.0.0.1:5432"
Starting the server
To start the web server, go to the mini-forum/
directory and execute:
$ ananc run
Visit http://127.0.0.1:9090/. If everything went well, you should see the default page. You can find the full code for this project here.
Creating an app
To create an app, go to mini-forum/src
and run:
$ ananc app forum
This will make a forum
directory with the following files:
.
├── migrations
├── mod.rs
├── records.rs
└── urls.rs
To include this app, add it to main.rs
:
mod forum;
apps! {
auth,
sessions,
forum,
}
Setting up records
To set up records, edit forum/records.rs
:
use anansi::records::{VarChar, DateTime, ForeignKey};
use anansi::util::auth::records::User;
#[record]
#[derive(Relate, FromParams)]
pub struct Topic {
pub title: VarChar<200>,
pub user: ForeignKey<User>,
pub content: VarChar<40000>,
pub date: DateTime,
}
#[record]
#[derive(Relate, FromParams)]
pub struct Comment {
pub topic: ForeignKey<Topic>,
pub user: ForeignKey<User>,
pub content: VarChar<40000>,
pub date: DateTime,
}
#[record]
adds an id
field by default, and functions that reference the record’s fields (like topic::date
), which can be used with methods like order_by
to query the database. Relate
handles access control between records, and FromParams
will allow you to get a record from a request’s parameters. Both records have ForeignKey
fields, which means that they have many-to-one relationships with Topic
and User
.
Adding the records
To prepare the migration files (which are used to keep track of the database), run:
$ ananc make-migrations forum/
If you want to view the SQL for this migration, you can run:
$ ananc sql-migrate forum 0001
The output will depend on which database you chose. For PostgreSQL, you should see something like:
CREATE TABLE "forum_topic" (
"id" bigint NOT NULL PRIMARY KEY,
"title" varchar(200) NOT NULL,
"user" bigint NOT NULL
REFERENCES "auth_user" ("id")
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED,
"content" varchar(40000) NOT NULL,
"date" timestamp NOT NULL
);
CREATE INDEX "forum_topic_user_index" ON "forum_topic" ("user");
--snip--
CREATE TABLE "forum_comment" (
"id" bigint NOT NULL PRIMARY KEY,
"topic" bigint NOT NULL
REFERENCES "forum_topic" ("id")
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED,
"user" bigint NOT NULL
REFERENCES "auth_user" ("id")
ON DELETE CASCADE
DEFERRABLE INITIALLY DEFERRED,
"content" varchar(40000) NOT NULL,
"date" timestamp NOT NULL
);
CREATE INDEX "forum_comment_topic_index" ON "forum_comment" ("topic");
CREATE INDEX "forum_comment_user_index" ON "forum_comment" ("user");
--snip--
To apply the migrations, run:
$ ananc migrate
Showing the records
You can start creating views by going to forum/
and running:
$ ananc make-view topic
Now edit forum/topic/views.rs
:
use super::super::records::{Topic, topic::date};
#[viewer]
impl<R: Request> TopicView<R> {
#[view(Site::is_visitor)]
pub async fn index(req: &mut R) -> Result<Response> {
let title = "Latest Topics";
let topics = Topic::order_by(date().desc())
.limit(25).query(req).await?;
}
}
Site::is_visitor
will check if the visitor is a visitor, which is always true. Then, it will put "Latest Topics"
into the title
variable. The last 25 topics are put into the topics
variable.
To use these variables, edit forum/topic/templates/index.rs.html
:
@block title {@title}
@block content {
<h1>@title</h1>
<ul>
@for topic in topics {
<li>@topic.title</li>
}
</ul>
}
In a template, the @
symbol allows the use of keywords. The @
symbol can also format variables into strings.
To add this view to the routes, change urls.rs
to the following:
use crate::forum::{self, topic::views::TopicView};
pub fn routes<R: Request>() -> Router<R> {
Router::new()
.route("", TopicView::index)
.nest("/topic", forum::urls::routes())
}
At this point, you can visit the index page you wrote at http://127.0.0.1:9090/, but there won’t be much to see since there aren’t any topics.
Components
You can use components to make pages interactive, though there is some set up involved, so you can skip this section if you’re not interested. If you want to keep going, you’ll need to install wasm-pack first if you haven’t already. Next, go to the mini-forum/
directory and run:
$ ananc init-components
This will create the mini-forum-comps
and mini-forum-wasm
crates. In mini-forum-comps/Cargo.toml
, add the following dependency:
gloo-net = { version = "0.2", features = ["http", "json"] }
Then create mini-forum-comps/src/loader.rs
:
use anansi_aux::prelude::*;
use gloo_net::http::Request;
#[derive(Properties, Serialize, Deserialize)]
pub struct LoaderProps {
pub load_url: String,
pub show_url: String,
}
#[derive(Serialize, Deserialize)]
pub struct Data {
pub id: String,
pub title: String,
}
#[store]
#[derive(Serialize, Deserialize)]
pub struct Loader {
visible: bool,
page: u32,
fetched: Vec<Data>,
}
#[component(Loader)]
fn init(props: LoaderProps) -> Rsx {
let mut state = Self::store(true, 1, vec![]);
let (data_resource, handle_click) = resource!(Vec<Data>, state, props, {
*state.visible_mut() = false;
let request = Request::get(&props.load_url)
.query([("page", state.page().to_string())]);
});
rsx!(state, props, data_resource, {
@for data in state.fetched() {
<li>@href props.show_url, data.id {@data.title}</li>
}
@resource data_resource, state {
Resource::Pending => {
<div>Loading...</div>
}
Resource::Rejected(_) => {
*state.visible_mut() = true;
<div>Problem loading topics</div>
}
Resource::Resolved(mut f) => {
if f.len() == 25 && *state.page() < 3 {
*state.page_mut() += 1;
*state.visible_mut() = true;
}
state.fetched_mut().append(&mut f);
}
}
@if *state.visible() {
<button @onclick(handle_click)>Load more</button>
}
}
}
This will create a button that will fetch additional topics when pressed. Now, edit mini-forum-comps/src/lib.rs
:
pub mod loader;
anansi_aux::app_components! {
loader::Loader,
}
Add the files to src/main.rs
:
app_statics! {
admin,
wasm_statics!("mini-forum-wasm"),
}
You can then put it in forum/topic/views.rs
, and add a load
function:
use anansi::{url, check, http_404};
use mini_forum_comps::loader::{Loader, Data};
#[viewer]
impl<R: Request> TopicView<R> {
#[view(Site::is_visitor)]
pub async fn index(req: &mut R) -> Result<Response> {
let title = "Latest Topics";
let topics = Topic::order_by(date().desc())
.limit(25).query(req).await?;
// Replace with url!(req, Self::show) later.
let show_url = "/topic";
let load_url = url!(req, Self::load);
}
#[check(Site::is_visitor)]
pub async fn load(req: &mut R) -> Result<Response> {
let page: u32 = req.params().get("page")?.parse()?;
if page > 3 {
http_404!();
}
let topics = Topic::order_by(date().desc())
.limit(25).offset(25 * page).query(req).await?;
let data: Vec<Data> = topics.iter().map(|t| {
Data {id: t.pk().to_string(), title: t.title.to_string()}
}).collect();
Ok(Response::from_json(serde_json::to_string(&data)?))
}
}
And use it in forum/topic/templates/index.rs.html
:
@block content {
@load components {
<h1>@title</h1>
<ul>
@for topic in &topics {
<li>@topic.title</li>
}
@if topics.len() == 25 {
<Loader @show_url @load_url />
}
</ul>
}
}
Don’t forget to add load
to forum/urls.rs
:
use super::topic::views::TopicView;
pub fn routes<R: Request>() -> Router<R> {
Router::new()
.route("load", TopicView::load)
}
Now, if you go to the mini-forum
directory and execute:
$ ananc run
Hopefully, it should all work. If you’ve never used wasm-pack before, the first compiliation may take a while.
Scoped CSS
If you want, you can create CSS that only applies to a specific component. First, create mini-forum-comps/src/spinner.rs
:
use anansi_aux::prelude::*;
#[function_component(Spinner)]
fn init() -> Rsx {
style! {
div {
display: inline-block;
width: 25px;
height: 25px;
border: 3px solid #cfd0d1;
border-radius: 50%;
border-top-color: #1c87c9;
animation: spin 1s ease-in-out infinite;
-webkit-animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
-webkit-transform: rotate(360deg);
}
}
@-webkit-keyframes spin {
to {
-webkit-transform: rotate(360deg);
}
}
}
rsx! {
<div></div>
}
}
This one uses function_component
since it doesn’t have to manage state, and the CSS goes in style
. Now, add everything to mini-forum-comps/src/lib.rs
:
pub mod spinner;
anansi_aux::comp_statics! {
"spinner",
}
anansi_aux::app_components! {
loader::Loader,
spinner::Spinner,
}
And edit src/main.rs
:
app_statics! {
admin,
wasm_statics!("mini-forum-wasm"),
eddit_comps,
}
Finally, you should be able to use it in mini-forum-comps/src/loader.rs
:
use crate::spinner::Spinner;
// --snip--
#[component(Loader)]
fn init(props: LoaderProps) -> Rsx {
// --snip--
rsx! {
@for data in &state.fetched {
<li>@href props.show_url, data.id {@data.title}</li>
}
@resource data_resource {
Resource::Pending => {
<Spinner />
}
// --snip--
}
@if state.visible {
<button @onclick(handle_click)>Load more</button>
}
}
}
Tailwind
If you have Tailwind and want to use it, first run:
$ ananc init-tailwind
Then edit forum/topic/templates/base.rs.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>@block title</title>
<link href="/static/styles/global.css" rel="stylesheet">
</head>
<body>
@block content
</body>
</html>
That should allow you to use Tailwind classes.
Caching
While caching may not be required for a small site, if you want to use it, there are a few steps involved. First add some dependencies to Cargo.toml
:
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
For the cache, there are two options. The default cache uses Moka, which may work for small to medium sites. For big sites, or if you just want to use Redis, add the feature "redis"
to Cargo.toml
. In src/project.rs
, change app_cache!(local)
to app_cache!(redis)
. Finally, in settings.toml
, change [caches.default]
to:
location = "redis://127.0.0.1/"
Add traits to forum/records.rs
:
use serde::{Serialize, Deserialize};
#[record]
#[derive(Relate, FromParams, Serialize, Deserialize)]
pub struct Topic {
pub title: VarChar<200>,
pub user: ForeignKey<User>,
pub content: VarChar<40000>,
pub date: DateTime,
}
Finally, in forum/topic/views.rs
:
use anansi::cache::prelude::*;
#[viewer]
impl<R: Request> TopicView<R> {
#[view(Site::is_visitor)]
pub async fn index(req: &mut R) -> Result<Response> {
let title = "Latest Topics";
let topics = cache!(req, Some(30), "topic_index", {
Topic::order_by(date().desc())
.limit(25).query(req).await?
});
}
}
This will cache the results of the query with the key "topic_index"
for 30 seconds.
Creating an admin
To create an admin, run:
$ ananc admin
Then enter a username, email, and a strong password.
Visit the admin site
If the server isn’t up, execute:
$ ananc run
Then, in a browser, go to the login page for the admin site (e.g. http://127.0.0.1:9090/admin/login), which has the login screen:
Admin dashboard
If you login to the admin page, you will be greeted by the admin index page:
Create a user
Next to “Users”, click “Add”, then fill the form to create a new user.
Views
Views handle what will happen when a url is visited.
Adding more views
Update forum/records.rs
to add more helper methods.
use anansi::db::OrderBy;
use anansi::ToUrl;
#[record]
#[derive(Relate, FromParams, Serialize, Deserialize, ToUrl)]
pub struct Topic {
pub title: VarChar<200>,
pub user: ForeignKey<User>,
pub content: VarChar<40000>,
pub date: DateTime,
}
impl Topic {
pub fn recent_comments(&self) -> OrderBy<Comment> {
Comment::by_topic(self).order_by(comment::date().desc())
}
}
ToUrl
will return a shortened version of id
(e.g. ixNr1-tGUe9
) by default. by_topic
, which was added by the ForeignKey
, filters results by parent.
Now edit forum/urls.rs
:
use super::topic::views::TopicView;
pub fn routes<R: Request>() -> Router<R> {
Router::new()
// e.g. /topic/ixNr1-tGUe9
.route("{topic_id}", TopicView::show)
}
{topic_id}
will capture the segment of the url it corresponds to (e.g. ixNr1-tGUe9
) in a parameter that can be accessed from the request. Since show
will match any url with the pattern /topic/SEGMENT
, it should be the last of any views with that pattern.
Update forum/topic/views.rs
:
use anansi::get_or_404;
use anansi::humanize::ago;
#[viewer]
impl<R: Request> TopicView<R> {
// --snip--
#[view(Site::is_visitor)]
pub async fn show(req: &mut R) -> Result<Response> {
let topic = get_or_404!(Topic, req);
let title = &topic.title;
let poster = topic.user.get(req).await?.username;
let comments = topic.recent_comments().limit(25).query(req).await?;
let users = comments.parents(req, |c| &c.user).await?;
}
}
In show
, get_or_404!
retrieves a specific topic by topic_id
or returns a 404 error. parents
queries the users associated with the comments.
Now let’s add the template for this view in forum/topic/templates/show.rs.html
:
@block title {@title}
@block content {
<h1>@title</h1>
<p><small>Posted by @poster @ago(topic.date)</small></p>
<p>@topic.content</p>
@for (comment, user) in comments.iter().zip(users.iter()) {
<p><small>Posted by @user.username @ago(comment.date)</small></p>
<p>@comment.content</p>
}
}
We can also go back to forum/topic/templates/index.rs
and link each topic to its page.
@block content {
<h1>@title</h1>
<ul>
@for topic in topics {
<li>@link req, Self::show, topic {@topic.title}</li>
}
</ul>
}
link
will create a link to a view (in this case, show
), which will expand to something like:
<a href="topic/@topic.to_url()">@topic.title</a>
Logging in
To log in, we can reuse the admin page’s user login form in forum/topic/views.rs
(of course, you can write your own if you want to):
use anansi::handle;
use anansi::forms::ToRecord;
use anansi::util::auth::forms::UserLogin;
#[viewer]
impl<R: Request> TopicView<R> {
// --snip--
#[view(Site::is_visitor)]
pub async fn login(req: &mut R) -> Result<Response> {
let title = "Log in";
let button = "Log in";
let form = handle!(UserLogin, ToRecord<R>, req, user, {
req.auth(&user).await?;
req.session().set_and_redirect(req, Self::index)
})?;
}
}
handle!
creates a new form if the request method is GET. Otherwise, it tries to do something with the submitted form (in this case, log in the user), and if that fails, gives back the form. The form can be used in forum/topic/templates/login.rs.html
:
@block title {@title}
@block content {
<h1>@title</h1>
<div>
@build form {
@unescape form.errors()
@for field in form.fields() {
@unescape field.label_tag()
<div>
@unescape field
@unescape field.errors()
</div>
}
@unescape form.submit(button)
}
</div>
}
Using the template system to build
forms should be safer and less tedious than writing pure HTML. Variables containing HTML tags must be unescape
d to be processed properly. The errors
method will display an unordered list of errors. Form errors will look something like:
<ul class="form-errors">
<li>Problem with username or password.</li>
</ul>
Errors in fields will have the class field-errors
instead.
To include everything, update urls.rs
:
pub fn routes<R: Request>() -> Router<R> {
Router::new()
.route("/", TopicView::index)
.nest("/topic", forum::urls::routes())
.route("/login", TopicView::login)
}
Now you can log in with the user you created in the admin page.
Creating
To add topics, we can start by creating a form in the file forum/forms.rs
:
use crate::prelude::*;
use anansi::records::{DateTime, ForeignKey};
use anansi::forms::{VarChar, ToRecord};
use super::records::Topic;
#[form(Topic)]
pub struct TopicForm {
pub title: VarChar<200>,
pub content: VarChar<40000>,
}
#[async_trait]
impl<R: Request> ToRecord<R> for TopicForm {
async fn on_post(&mut self, data: TopicFormData, req: &R) -> Result<Topic> {
Topic::new()
.title(data.title)
.user(ForeignKey::from_data(req.user().pk())?)
.content(data.content)
.date(DateTime::now())
.saved(req)
.await
.or(form_error!("Problem adding topic"))
}
}
#[form(Topic)]
generates a TopicFormData
struct, which holds the data for the form, and associates the form with Topic
. on_post
will try to convert the form to a record. form_error
will simply create a FormError
, which can be accessed with the form’s errors
method.
Update forum/mod.rs
:
pub mod forms;
Now use it in forum/topic/views.rs
:
use anansi::{check, extend};
use crate::forum::forms::TopicForm;
#[viewer]
impl<R: Request> TopicView<R> {
// --snip--
#[check(Site::is_auth)]
pub async fn new(req: &mut R) -> Result<Response> {
let title = "New Topic";
let button = "Create";
let form = handle!(TopicForm, ToRecord<R>, req, |topic| {
Ok(redirect!(req, Self::show, topic))
})?;
extend!(req, base, "login")
}
}
Site::is_auth
will redirect the visitor if they aren’t authenticated. This time, for handle
, a closure is passed this time since redirection isn’t async. For the template, in this simple case, you can just reuse login.rs.html
by using the check
and extend
macros, though for an actual site, you’d probably write a custom one. You can also have a link to the page added in index.rs.html
if the user is authenticated:
@block content {
<h1>@title</h1>
@if req.user().is_auth() {
@link req, Self::new {New Topic}
}
<ul>
@for topic in topics {
<li>@link req, Self::show, topic {@topic.title}</li>
}
</ul>
}
To bring it all together, update forum/urls.rs
:
pub fn routes<R: Request>() -> Router<R> {
Router::new()
.route("new", TopicView::new)
.route("{topic_id}", TopicView::show)
}
Well, that was a lot of code, but now you can finally create a topic!
Updating
To edit topics, we can first add some traits to forum/forms.rs
:
use anansi::{GetData, ToEdit};
#[form(Topic)]
#[derive(GetData, ToEdit)]
pub struct TopicForm {
pub title: VarChar<200>,
pub content: VarChar<40000>,
}
And add a view to forum/topic/views.rs
:
use anansi::handle_or_404;
use anansi::forms::ToEdit;
#[viewer]
impl<R: Request> TopicView<R> {
// --snip--
#[check(Topic::owner)]
pub async fn edit(req: &mut R) -> Result<Response> {
let title = "Update Topic";
let button = "Update";
let form = handle_or_404!(TopicForm, ToEdit<R>, req, |topic| {
Ok(redirect!(req, Self::show, topic))
})?;
extend!(req, base, "login")
}
}
Topic::owner
checks if the user owns the topic. handle_or_404!
is like handle!
, but returns a 404 error if the record can’t be found. Like before, for the template, you can just reuse login.rs.html
. You can also have the link for it added in show.rs.html
if the topic is the user’s:
@block content {
<h1>@title</h1>
<p><small>Posted by @poster @ago(topic.date)</small></p>
<p>@topic.content</p>
@if topic.user.pk() == req.user().pk() {
@link req, Self::edit, topic {Edit}
}
@for (comment, user) in comments.iter().zip(users.iter()) {
<p><small>Posted by @user.username @ago(comment.created)</small></p>
<p>@comment.content</p>
}
}
As always, update forum/urls.rs
:
pub fn routes<R: Request>() -> Router<R> {
Router::new()
.route("new", TopicView::new)
.route("{topic_id}", TopicView::show)
.route("{topic_id}/edit", TopicView::edit)
}
Admin
At this point, you can add the Topic
record to the admin page. First, create forum/admin.rs
:
use anansi::{init_admin, register, record_admin};
use super::records::Topic;
init_admin! {
register!(Topic),
}
record_admin! {Topic,
// You can specify which fields (if any) should be searchable
search_fields: [title, content, date],
}
Add it to forum/mod.rs
:
pub mod admin;
Finally, add the app to main.rs
:
app_admins! {
auth,
forum,
}
Topic
should show up on the admin page now.
Deleting
To delete topics edit forum/topic/views.rs
:
#[viewer]
impl<R: Request> TopicView<R> {
// --snip--
#[view(Topic::owner)]
pub async fn destroy(req: &mut R) -> Result<Response> {
let title = "Delete topic";
let topic = get_or_404!(Topic, req);
let form = handle!(req, R, {
topic.delete(req).await?;
Ok(redirect!(req, Self::index))
})?;
}
}
forum/topic/templates/destroy.rs.html
is relatively simple:
@block title {@title}
@block content {
<h1>@title</h1>
Are you sure you want to delete the topic "@topic.title"?
@build form {
@unescape form.submit("Confirm")
}
}
Again, you can add the link for it in forum/topic/templates/show.rs.html
:
@block content {
<h1>@title</h1>
<p><small>Posted by @poster @ago(topic.date)</small></p>
<p>@topic.content</p>
@if topic.user.pk() == req.user().pk() {
@link req, Self::edit, topic {Edit}
@link req, Self::destroy, topic {Delete}
}
@for (comment, user) in comments.iter().zip(users.iter()) {
<p><small>Posted by @user.username @ago(comment.created)</small></p>
<p>@comment.content</p>
}
}
And finally, update forum/urls.rs
:
pub fn routes<R: Request>() -> Router<R> {
Router::new()
.route("new", TopicView::new)
.route("{topic_id}", TopicView::new)
.route("{topic_id}/edit", TopicView::edit)
.route("{topic_id}/destroy", TopicView::destroy)
}
There are more features you can add, but hopefully, this tutorial is enough for you to get started.