Working With Genie Apps: Intermediate Topics
WARNING: THIS PAGE IS UNDER CONSTRUCTION – THE CONTENT IS USABLE BUT INCOMPLETE
Handling forms
Now, the problem is that Bill Gates reads – a lot! It would be much easier if we would allow our users to add a few books themselves, to give us a hand. But since, obviously, we're not going to give them access to our Julia REPL, we should setup a web page with a form. Let's do it.
We'll start by adding the new routes:
# routes.jl
route("/bgbooks/new", BooksController.new)
route("/bgbooks/create", BooksController.create, method = POST, named = :create_book)
The first route will be used to display the page with the new book form. The second will be the target page for submitting our form - this page will accept the form's payload. Please note that it's configured to match POST
requests and that we gave it a name. We'll use the name in our form so that Genie will dynamically generate the correct links to the corresponding URL (to avoid hard coding URLs). This way we'll make sure that our form will always submit to the right URL, even if we change the route (as long as we don't change the name).
Now, to add the methods in BooksController
. Add these definition under the billgatesbooks
function (make sure you add them in BooksController
, not in BooksController.API
):
# BooksController.jl
function new()
html(:books, :new)
end
function create()
# code here
end
The new
method should be clear: we'll just render a view file called new
. As for create
, for now it's just a placeholder.
Next, to add our view. Add a blank file called new.jl.html
in app/resources/books/views
. Using Julia:
julia> touch("app/resources/books/views/new.jl.html")
Make sure that it has this content:
<!-- app/resources/books/views/new.jl.html -->
<h2>Add a new book recommended by Bill Gates</h2>
<p>
For inspiration you can visit <a href="https://www.gatesnotes.com/Books" target="_blank">Bill Gates' website</a>
</p>
<form action="$(Genie.Router.linkto(:create_book))" method="POST">
<input type="text" name="book_title" placeholder="Book title" /><br />
<input type="text" name="book_author" placeholder="Book author" /><br />
<input type="submit" value="Add book" />
</form>
Notice that the form's action calls the linkto
method, passing in the name of the route to generate the URL, resulting in the following HTML: <form method="POST" action="/bgbooks/create">
.
We should also update the BooksController.create
method to do something useful with the form data. Let's make it create a new book, persist it to the database and redirect to the list of books. Here is the code:
# BooksController.jl
using Genie.Router, Genie.Renderer
function create()
Book(title = params(:book_title), author = params(:book_author)) |> save && redirect(:get_bgbooks)
end
A few things are worth pointing out in this snippet:
- again, we're accessing the
params
collection to extract the request data, in this case passing in the names of our
form's inputs as parameters.
We need to bring Genie.Router
into scope in order to access params
;
- we're using the
redirect
method to perform a HTTP redirect. As the argument we're passing in the name of the route,
just like we did with the form's action. However, we didn't set any route to use this name. It turns out that Genie gives default names to all the routes.
We can use these – but a word of notice: these names are generated using the properties of the route, so if the route changes it's possible that the name will change too. So either make sure your route stays unchanged – or explicitly name your routes. The autogenerated name, get_bgbooks
corresponds to the method (GET
) and the route (bgbooks
).
In order to get info about the defined routes you can use the Router.named_routes
function:
julia> Router.named_routes()
OrderedCollections.OrderedDict{Symbol, Genie.Router.Route} with 6 entries:
:get_bgbooks => [GET] /bgbooks => billgatesbooks | :get_bgbooks
:get_bgbooks_new => [GET] /bgbooks/new => new | :get_bgbooks_new
:get => [GET] / => () | :get
:get_api_v1_bgbooks => [GET] /api/v1/bgbooks | :get_api_v1_bgbooks
:create_book => [POST] /bgbooks/create | :create_book
Let's try it out. Input something and submit the form. If everything goes well a new book will be persisted to the database – and it will be added at the bottom of the list of books.
Uploading files
Our app looks great – but the list of books would be so much better if we'd display the covers as well. Let's do it!
Modify the database
The first thing we need to do is to modify our table to add a new column, for storing a reference to the name of the cover image. Per best practices, we'll use database migrations to modify the structure of our table:
julia> SearchLight.Generator.newmigration("add cover column")
[debug] New table migration created at db/migrations/2019030813344258_add_cover_column.jl
Now we need to edit the migration file - please make it look like this:
# db/migrations/*_add_cover_column.jl
module AddCoverColumn
import SearchLight.Migrations: add_column, add_index
# SQLite does not support column removal so the `remove_column` method is not implemented in the SearchLightSQLite adapter
# If using SQLite leave the next line commented -- otherwise uncomment it
# import SearchLight.Migrations: remove_column
function up()
add_column(:books, :cover, :string)
end
function down()
# if using the SQLite backend, leave the next line commented -- otherwise uncomment it
# remove_column(:books, :cover)
end
end
Looking good - lets ask SearchLight to run it:
julia> SearchLight.Migration.lastup()
[debug] Executed migration AddCoverColumn up
If you want to double check, ask SearchLight for the migrations status:
julia> SearchLight.Migration.status()
| | Module name & status |
| | File name |
|---|----------------------------------------|
| | CreateTableBooks: UP |
| 1 | 2018100120160530_create_table_books.jl |
| | AddCoverColumn: UP |
| 2 | 2019030813344258_add_cover_column.jl |
Perfect! Now we need to add the new column as a field to the Books.Book
model:
module Books
using SearchLight, SearchLight.Validation, BooksValidator
export Book
Base.@kwdef mutable struct Book <: AbstractModel
id::DbId = DbId()
title::String = ""
author::String = ""
cover::String = ""
end
end
As a quick test we can extend our JSON view and see that all goes well - make it look like this:
# app/resources/books/views/billgatesbooks.json.jl
"Bill's Gates list of recommended books" => [Dict("author" => b.author,
"title" => b.title,
"cover" => b.cover) for b in books]
If we navigate http://localhost:8000/api/v1/bgbooks you should see the newly added "cover" property (empty, but present).
Heads up!
Sometimes Julia/Genie/Revise fails to update structs
on changes. If you get an error saying that Book
does not have a cover
field or that it can not be changed, you'll need to restart the Genie app.
File uploading
Next step, extending our form to upload images (book covers). Please edit the new.jl.html
view file as follows:
<h3>Add a new book recommended by Bill Gates</h3>
<p>
For inspiration you can visit <a href="https://www.gatesnotes.com/Books" target="_blank">Bill Gates' website</a>
</p>
<form action="$(Genie.Router.linkto(:create_book))" method="POST" enctype="multipart/form-data">
<input type="text" name="book_title" placeholder="Book title" /><br />
<input type="text" name="book_author" placeholder="Book author" /><br />
<input type="file" name="book_cover" /><br />
<input type="submit" value="Add book" />
</form>
The new bits are:
- we added a new attribute to our
<form>
tag:enctype="multipart/form-data"
. This is required in order to support files payloads. - there's a new input of type file:
<input type="file" name="book_cover" />
You can see the updated form by visiting http://localhost:8000/bgbooks/new
Now, time to add a new book, with the cover! How about "Identity" by Francis Fukuyama? Sounds good. You can use whatever image you want for the cover, or maybe borrow the one from Bill Gates, I hope he won't mind https://www.gatesnotes.com/-/media/Images/GoodReadsBookCovers/Identity.png. Just download the file to your computer so you can upload it through our form.
Almost there - now to add the logic for handling the uploaded file server side. Please update the BooksController.create
method to look like this:
# BooksController
function create()
cover_path = if haskey(filespayload(), "book_cover")
path = joinpath("img", "covers", filespayload("book_cover").name)
write(joinpath("public", path), IOBuffer(filespayload("book_cover").data))
path
else
""
end
Book( title = params(:book_title),
author = params(:book_author),
cover = cover_path) |> save && redirect(:get_bgbooks)
end
Also, very important, you need to make sure that BooksController
is using Genie.Requests
.
Regarding the code, there's nothing very fancy about it. First we check if the files payload contains an entry for our book_cover
input. If yes, we compute the path where we want to store the file, write the file, and store the path in the database.
Please make sure that you create the folder covers/
within public/img/
.
Great, now let's display the images. Let's start with the HTML view - please edit app/resources/books/views/billgatesbooks.jl.html
and make sure it has the following content:
<!-- app/resources/books/views/billgatesbooks.jl.html -->
<h1>Bill's Gates top $( length(books) ) recommended books</h1>
<ul>
<%
for_each(books) do book
%>
<li><img src='$( isempty(book.cover) ? "img/docs.png" : book.cover )' width="100px" /> $(book.title) by $(book.author)</li>
<%
end
%>
</ul>
Here we check if the cover
property is not empty, and display the actual cover. Otherwise we show a placeholder image. You can check the result at http://localhost:8000/bgbooks
As for the JSON view, it already does what we want - you can check that the cover
property is now outputted, as stored in the database: http://localhost:8000/api/v1/bgbooks
Success, we're done here!
Heads up!
In production you will have to make the upload code more robust - the big problem here is that we store the cover file as it comes from the user which can lead to name clashes and files being overwritten - not to mention security vulnerabilities. A more robust way would be to compute a hash based on author and title and rename the cover to that.
One more thing
So far so good, but what if we want to update the books we have already uploaded? It would be nice to add those missing covers. We need to add a bit of functionality to include editing features.
First things first - let's add the routes. Please add these two new route definitions to the routes.jl
file:
route("/bgbooks/:id::Int/edit", BooksController.edit)
route("/bgbooks/:id::Int/update", BooksController.update, method = POST, named = :update_book)
We defined two new routes. The first will display the book object in the form, for editing. While the second will take care of actually updating the database, server side. For both routes we need to pass the id of the book that we want to edit - and we want to constrain it to an Int
. We express this as the /:id::Int/
part of the route.
We also want to:
- reuse the form which we have defined in
app/resources/books/views/new.jl.html
- make the form aware of whether it's used to create a new book, or for editing an existing one respond accordingly by setting the correct
action
- pre-fill the inputs with the book's info when editing a book.
OK, that's quite a list and this is where things become interesting. This is an important design pattern for CRUD web apps. In order to simplify the rendering of the form, we will always pass a book object into it. When editing a book it will be the book corresponding to the id
passed into the route
. And when creating a new book, it will be just an empty book object we'll create and then dispose of.
Using view partials
First, let's set up the views. In app/resources/books/views/
please create a new file called form.jl.html
. Then, from app/resources/books/views/new.jl.html
cut the <form>
code. That is, everything between the opening and closing <form>...</form>
tags. Paste it into the newly created form.jl.html
file. Now, back to new.jl.html
, instead of the previous <form>...</form>
code add:
<% partial("app/resources/books/views/form.jl.html", context = @__MODULE__) %>
This line, as the partial
function suggests, includes a view partial, which is a part of a view file, effectively including a view within another view. Notice that we're explicitly passing the context
so Genie can set the correct variable scope when including the partial.
You can reload the new
page to make sure that everything still works: http://localhost:8000/bgbooks/new
Now, let's add an Edit option to our list of books. Please go back to our list view file, billgatesbooks.jl.html
. Here, for each iteration, within the for_each
block we'll want to dynamically link to the edit page for the corresponding book.
for_each
with view partials
However, this for_each
which renders a Julia string is very ugly - and we now know how to refactor it, by using a view partial. Let's do it. First, replace the body of the for_each
block:
<!-- app/resources/books/views/billgatesbooks.jl.html -->
"""<li><img src='$( isempty(book.cover) ? "img/docs.png" : book.cover )' width="100px" /> $(book.title) by $(book.author)"""
with:
partial("app/resources/books/views/book.jl.html", book = book, context = @__MODULE__)
Notice that we are using the partial
function and we pass the book object into our view, under the name book
(will be accessible in book
inside the view partial). Again, we're passing the scope's context
(our controller object).
Next, create the book.jl.html
in app/resources/books/views/
, for example with
julia> touch("app/resources/books/views/book.jl.html")
Add this content to it: TO BE CONTINUED